Java虚拟机(JVM)原理与特性
Java虚拟机的主要作用是从软件层面上屏蔽不同操作系统在底层硬件与指令上的区别,使得Java语言具有很好的跨平台性。完整的Java虚拟机由三部分组成:类装载子系统、运行时数据区(Java内存区域)、字节码执行引擎。
一、Java内存区域
1. 运行时数据区域(Java内存模型)
1.1 程序计数器(Program Counter Register)
程序计数器是当前线程所执行的字节码的行号计数器。程序计数器也是“线程私有”的。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器的执行时间的方式来实现的,为了线程切换后能够恢复正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的程序计数器独立存储,互不影响。字节码执行引擎会动态修改程序计数器的值。
1.2 虚拟机栈(Virtual Machine Stacks)
Java虚拟机栈是线程私有的(线程隔离的数据区),它的生命周期与线程相同。虚拟机栈也可以理解为线程栈,也就是说Java虚拟机会在虚拟机栈中为每一个线程分配一个独立的内存区域。Java虚拟机栈描述的是方法执行时的内存模型,虚拟机栈是为虚拟机执行Java方法(字节码)服务的:每个方法在执行的同时都会创建一个栈桢(Stack Frame)。
栈帧内存区域是用于支持虚拟机进行方法调用和方法执行的数据结构,用于存储局部变量表、操作数栈、动态链接、方法出口(方法返回地址)和一些额外的附加信息。每一个方法从调用开始直至执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。在程序编译时期,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了并且已经被写入到了方法表的Code属性中了。因此一个栈帧需要分配多少的内存,不会受到程序运行时期变量数据值的影响而仅仅取决于具体的虚拟机实现。在活动线程中,只有位于虚拟机栈顶部的栈帧才是有效的,称为当前栈帧(因为栈是后入先出的数据结构,只能在栈顶操作数据),与当前栈帧相关联的方法称为当前方法,执行引擎所执行的字节码指令都是针对当前方法操作的。
局部变量表是一组变量值的存储空间,用于存储方法参数和方法内部定义的局部变量。
局部变量表的容量是以变量槽(slot)位最小单位。
Java虚拟机的数据类型:一个slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean(1个字节)、byte(1个字节)、char(2个字节)、short(2个字节)、int(4个字节)、float(4个字节)、reference(对象引用:一是从此引用中直接或间接地查找到对象在Java堆中数据存放的起始地址索引,而是从此引用中直接或间接地查找到对象所属的数据类型在方法区中存储的类型信息。reference中实际存放的就是对象在Java堆中的内存首地址的值)和returnAddress(指向一条字节码指令的地址)这8种类型。而对于64位的数据类型(long和double都是占8个字节),虚拟机会以高位对齐的方式为其分配2个连续的slot空间。
值得注意局部变量(例:方法内部定义的局部变量)和类变量(成员变量)的区别:
类变量存在2次初始化,第一次初始化是在类加载的“准备阶段”(正式为类变量分配内存并设置类变量的初始值)为类变量赋予系统初始值,第二次是在“初始化阶段”根据程序员通过程序制定的主观计划赋予自定义的初始值。因此即使在初始化阶段没有赋予程序员定义的初始值,类变量仍然具有一个系统初始值。而局部变量如果定义了但没有赋予初始值的话是不能使用的。
操作数栈是在代码执行过程中临时存放操作数的一块内存区域,其中的每一个元素可以是任意的Java数据类型,包括32位的数据类型(boolean,byte,char,short,int,float,reference类型,returnAddress类型)和64位的数据类型(long ,double)。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
动态链接:每个栈桢都包含一个指向运行时常量池(方法区的一部分)中该栈桢所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
方法返回地址(方法出口):退出方法有2种方式,一种是正常完成出口(执行引擎遇到任意一个方法返回的字节码指令,此时可能会与返回值传递给上层方法调用者),另一种是异常完成出口(在方法执行过程中遇到了异常但是没有在该方法的内部处理掉该异常,此时是不会给它的上层调用者产生任何返回值的)。无论是采用何种退出方式,在方法退出之后,都需要回到方法被调用的位置,程序才能继续执行,因此在方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。方法正常退出时,调用者的程序计数器的值可以作为返回地址,栈桢中可能会保存计数器的值;方法异常退出时,由于返回地址是由异常表确定的,因此栈桢中不会保存这部分信息。
在实际开发中,通常会将动态链接、方法返回地址和其他的一些附加信息统称为栈帧信息。
在虚拟机栈中存在两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的栈深度——抛出StackOverflowError异常;
如果虚拟机栈可以动态扩展,但是在扩展时无法申请到足够的内存——抛出OutOfMemoryError异常。
1.3 本地方法栈(Native Method Stack)
本地方法栈是为虚拟机执行Native方法服务的,用于存放本地方法(被native修饰的)的一块内存区域。本地方法栈也会抛出StackOverflowError异常和OutOfMemoryError异常。
1.4 Java堆(Heap)
Java堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。所有对象实例以及数组都要在堆上分配。Java堆可以处于物理上不连续的内存空间中只要逻辑上是连续的即可,在实现时,即可以实现为固定大小的,又可以实现为可扩展的(通过参数最小堆容量-xms和最大堆容量-xmx控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
1.5 方法区(元空间)
方法区是各个线程共享的内存区域,它用于存储已经被虚拟机加载过的类信息(通过字节码执行引擎去执行)、常量、静态变量(被static修饰的成员变量)、即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。
运行时常量池是方法区中的一部分。Class文件中包含有类的版本、字段、方法、接口等描述信息以及常量池(存放编译时期生成的各种字面量和符号引用)。Class文件中的常量池将在类加载后进入方法区中的运行时常量池存放。一般来说,运行时常量池中除了会存放Class文件常量池中的符号引用以外,还会存放翻译过来的直接引用。运行时常量池相对于Class文件常量池来说具备的一个重要的特征就是动态性,既能接收编译时期产生的常量(预置入Class文件的常量池中),也能接收运行时期新的常量。当运行时常量池无法再申请到内存时,将会抛出OutOfMemoryError异常。
2. 对象的内存布局
在HotSpot虚拟机中,对象的内存布局分为3个部分:对象头、实例数据和对齐填充。
2.1 对象头
对于非数组类型的对象,对象头中包含两部分信息:
(1)存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)即“Mark Word”。HotSpot虚拟机的对象头Mark Word如下:
存储内容 | 锁状态标志 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 重量级锁定(膨胀) |
空(不需要记录信息) | 11 | GC标记 |
对象分代年龄,偏向线程ID,偏向时间戳 | 01 | 可偏向(偏向锁) |
其中“对象分代年龄”是指:对象经历过几次GC,它的年龄就是几。
(2)存储指向方法区对象所属类型数据的指针(类型指针),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是属于哪个类的实例。
对于数组类型的对象,对象头中包含三部分信息:
(1)存储对象自身的运行时数据;
(2)存储指向方法区中对象所属类型数据的指针(类型指针);
(3)存储数组的长度。
2.2 实例数据
实例数据是对象真正存储的有效信息(程序代码中各种类型中的字段内容),无论是从父类继承的还是在子类中自己定义的(在父类中定义的变量会出现在子类中定义的变量之前),都需要记录下来 。
2.3 对齐填充
对齐填充不是必然存在的,仅仅起着占位符的作用。
三、 垃圾收集器与内存分配策略
1. 判断对象是否“存活”的算法
(1)引用计数算法(判断对象的引用数量)
给对象添加一个引用计数器,当有一个地方引用这个对象时引用计数器的值就加1,当这个引用失效时引用计数器的值就减1,任何情况下引用计数器值为0的对象就不可再被使用了。该算法比较简单,但是有一个弊端:无法解决对象之间相互循环引用的问题。
(2)可达性分析算法(判断对象的引用链是否可达)
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达),则这个对象就是不可用的。
可作为GC Roots的对象有:
虚拟机栈(栈帧中的局部变量表)所引用的对象;
方法区中静态变量所引用的对象;
方法区中常量所引用的对象;
本地方法栈中JNI(被native所修饰的方法)所引用的变量。
如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那么它会被第一次标记并且进行一次筛选(筛选的条件是:此对象是否有必要执行finalize()方法——虚拟机将“没有覆盖finalize()方法”和“finalize()方法已经被虚拟机调用过”这两种情况都是为没有必要执行finalize()方法),如果对象有必要执行finalize()方法,则这个对象会被放置在一个被称为F-Queue的队列中,稍后GC将会对F-Queue队列中的对象进第二次小规模的标记,如果对象在finalize()中成功拯救自己即与GC Roots引用链上的任何一个对象进行关联即可。
2. 引用的类型
引用强度由强到若依次为:
(1)强引用
只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
(2)软引用
软引用用来描述一些还有用但非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围之内进行第二次回收,如果这次回收后还是没有足够的内存才会抛出内存溢出异常。
(3)弱引用
弱引用用来描述一些还有用但非必须的对象。被弱引用关联着的对象只能存活到下一次垃圾收集之前,当垃圾收集器工作时,无论此时是否有足够的内存,都会回收掉被弱引用关联着的对象。
(4)虚引用(幽灵引用或者幻影引用)
为一个对象设置虚引用关联的唯一的目的就是在这个对象被收集器回收时会收到一个系统通知。
3. 垃圾收集算法
(1)标记-清除算法
标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象。
缺点:效率低;会产生大量不连续的内存碎片。
(2)复制算法(新生代)
将内存分为1块较大的Eden空间和2块较小的Survivor空间(Eden:Survivor:Survivor = 8:1:1),每次使用Eden空间和其中的一块Survivor空间。当回收时,将Eden空间和Survivor空间中仍存活的对象一次性复制到另外一块Survivor空间中,最后再清理掉Eden空间和刚才使用过的Survivor空间。
分配担保机制:如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,那么
这些对象将通过分配担保机制直接进入老年代。
(3)标记-整理算法(老年代)
标记出所有需要回收的对象,然后让所有仍然存活的对象都向一端移动,然后直接清理掉边界以外的内存。
(4)分代收集算法
将Java堆分为新生代和老年代。新生代中的对象具有“朝生夕灭”的特点,即在每次垃圾收集时都有大批对象死去,只有少量对象存活,因而选用“复制”算法;老年代中的对象存活时间长且没有额外空间对其进行分配担保,因而选用“标记-清除”算法或者“标记-整理”算法。
4. 垃圾收集器
4.1 新生代收集器
1. Serial
Serial收集器是最基本的、历史最悠久的单线程的垃圾收集器,是虚拟机工作在Client模式下默认的新生代收集器。Serial收集器的“单线程”意味着:它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,同时它在进行垃圾收集时必须暂停其他所有工作线程直到它收集结束(Stop The World)。
为什么要进行"Stop The World"?
因为在通过可达性分析算法来判断对象是否存活时,我们需要从一系列被称为“GC Roots”的对象开始向下搜索,寻找与该“GC Roots”有引用链相联的对象即存活对象。若没有暂停掉用户线程,可能用户线程(比如main线程)可以已经执行完了,作为“GC Roots”的对象(比如mian方法中定义的局部变量所引用的对象)被内存释放掉了,此时就无法判断原本在该“GC Roots”的引用链上的对象是否仍然存活了。
2. ParNew
ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集以外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。ParNew收集器是许多运行在Server模式下的虚拟机首选的新生代收集器。
并行:指多条垃圾收集线程同时工作,但此时用户线程仍然处于等待状态;
并发:指用户线程和垃圾收集线程同时工作(但不一定是并行的,肯能是交替执行的),用户程序再继续运行,垃圾收集程序运行于另一个CPU上。
3. Parallel Scavenge(“吞吐量优先”收集器)
Parallel Scavenge收集器是一个新生代的、使用“复制收集算法”的、并行多线程的垃圾收集器,但它与ParNew收集器的区别在于:
(1)关注点不同:CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;而Parallel Scavenge收集器的关注点是达到一个可控制的吞吐量(吞吐量 = 运行用户代码的事件 / (运行用户代码的时间 + 垃圾收集时间)),Parallel Scavenge收集器提供了2个可以控制吞吐量的参数分别是最大垃圾收集停顿时间-XX:MaxGCPauseMillis和直接设置吞吐量大小的参数-XX:GCTimeRatio(n,则允许的最大垃圾收集时间占总时间的比率为1/(1+n))。
(2)Parrallel Scavenge收集器具有GC自适应调节策略:Parrallel Scavenge收集器具有一个值得关注的参数——-XX:+UseAdaptiveSizePolicy,这是一个开端参数,当这个参数打开以后,就不需要手工指定新生代的大小(-Xmn)、Eden区与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以提供最合适的用户停顿时间或者最大的吞吐量。
4.2 老年代收集器
1. Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,也是一种单线程的收集器,使用*“标记-整理”算法*,给Client模式下的虚拟机使用。Serial Old收集器可以与Serial收集器、ParNew收集器或者Parallel Scavenge收集器搭配使用,也可以作为CMS并发收集发生Concurrent Mode Failure时使用后备预案。
2. Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和*“标记-整理”算法*。Parallel Old收集器只能与Parallel Scavenge收集器搭配使用,用于注重吞吐量和CPU资源敏感的场合。
3. CMS(Concurrent Mark Sweep)收集器
CMS收集器是以获取最短回收停顿时间为目标的收集器,使用*“标记-清除”算法*。CMS收集器的工作过程分为以下4个步骤:
(1)初始标记:标记一下GC Roots能直接关联到的对象,速度很快(Stop The World);
(2)并发标记:进行GC Roots的Tracing过程;
(3)重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那部一分对象的标记记录(Stop The World);
(4)并发清除。
优点:并发收集;低停顿。——“并发低停顿收集器”
缺点:
(1)对CPU资源非常敏感;
(2)无法处理浮动垃圾(在CMS并发清除阶段用户线程还在运行着从而产生新的垃圾,这部分垃圾出现在标记过程之后,CMS无法在当次收集过程中清除掉它们,只能留待下一次垃圾收集时清除掉),如果CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案——临时启用Serial Old收集器来重新进行老年代的垃圾收集即导致了另一次Full GC产生;
(3)由于采用“标记-清除”算法,会导致大量的内存碎片(空间碎片)产生,当空间碎片过多时,就可能会出现由于无法找到足够大的连续空间来分配大对象而不得不提前触发一次Full GC。
4.3 G1收集器
G1收集器是一款面向服务端应用的收集器。G1收集器的特点如下:
(1)并行与并发:G1能充分利用多CPU、多核的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop The World的停顿时间;
(2)分代收集:虽然G1不需要其他收集器的配合就可以独立地管理整个GC堆,但是它能够采取不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获得更好的垃圾收集效果;
(3)空间整合:G1收集器从整体上看是使用“标记-整理”算法,从局部上看是使用“复制”算法,这两种算法都意味着在G1运作期间不会产生内存空间碎片,收集后可以提供规整的可用内存;
(4)可预测的停顿:追求低停顿,建立可预测的停顿时间模型。
G1收集器将整个Java堆划分为多个大小相等的独立区域Region,虽然G1收集器仍然保留了新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,而是一部分Region(不需要连续)的集合。
G1收集器的运作步骤:
(1)初始标记:标记一下GC Roots能直接关联到的对象,并修改TAMS(Next Top At Mark Start)的值,让下一阶段用户程序并发运行的时候能在正确可用的Region中创建对象(需要停顿线程Stop The World,但耗时很短);
(2)并发标记:从GC Root开始对堆中的对象进行可达性分析,找出存活的对象(这段耗时较长,但可以与用户程序并发执行);
(3)最终标记:修正在并发标记时期由于用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,虚拟机将这段时间对象变化记录在Remembered Set Logs里面,在最终标记阶段需要把Remembered Set Logs中的数据合并到Remebered Set(在G1收集器中,各个Region之间的对象引用是通过Remembered Set来避免进行全堆扫描的)中(需要停顿线程Stop The World,但是可以并行执行)
(4)筛选回收:对各个Region的回收价值和回收成本进行排序,在后台维护一个优先列表,根据用户所期望的GC停顿时间来制定回收计划,优先回收价值最大的Region。
5. 内存分配与回收策略
Minor GC(新生代GC):是指发生在新生代的垃圾收集动作,因为Java对象大多都具备“朝生夕灭”的特性,因此Minor GC非常频繁,一般回收速度也比较快;
Full GC(Major GC,老年代GC):发生在老年代的GC(Full GC会对整个Java堆进行垃圾收集),出现了Full GC,经常会伴随着至少一次的Minor GC(但并非绝对,在Parallel Scavenge收集器的垃圾收集策略中就有直接进行Full GC的策略选择过程),Full GC的回收速度一般要比Minor GC慢10倍以上。
这两种GC都是由字节码执行引擎执行的垃圾收集线程。
在Java堆中默认的比例是,新生代占1/3,老年代占2/3。而新生代中内存又分为一块较大的Eden区和两块较小的Survivor区,且Eden:Survivor(s0):Survivor(s1) = 8:1:1(因为参数-XX:SurvivorRatio默认值为8)。
对象优先在Eden区分配(在大多数情况下,新创建出来的对象会优先在新生代的Eden区中分配),而当Eden区中没有足够的空间进行分配时,虚拟机将发起一次Minor GC(从GC Root开始对Eden区中的所有对象进行可达性分析,找出存活的对象即非垃圾对象,并将这些非垃圾对象一次性复制到一块Survivor(s0)区中,然后清理掉Eden区中的垃圾对象)。如果继续创建新对象并在Eden区中分配,此时若Eden区中没有足够的空间进行分配时,虚拟机将再次发起一次Minor GC(此时从GC Root开始对Eden区和刚才已使用过的Survivor(s0)区中的所有对象进行可达性分析,找出存活的对象即非垃圾对象,并将这些非垃圾对象一次性地复制到另一块尚未使用的空的Survivor(s1)区,然后清理掉Eden区和刚才已使用过的Survivor(s0)区中的垃圾对象)。如果再继续创建新对象并在Eden区中分配,此时若Eden区中没有足够的空间进行分配时,虚拟机将再次发起一次Minor GC(此时从GC Root开始对Eden区和刚才已使用过的非空的Survivor(s1)区中的所有对象进行可达性分析,找出存活的对象即非垃圾对象,并将这些非垃圾对象一次性地复制到另一块尚未使用的空的Survivor(s0)区,然后清理掉Eden区和刚才已使用过的Survivor(s1)区中的垃圾对象)。
对象分代年龄(GC分代年龄):虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且存活对象能够被Survivor容纳的话,将被移到Survivor空间中,并且将这些对象的年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中,也就是说这种长期存活的对象(GC分代年龄大于15岁)将进入老年代。对象晋升老年代的年龄阈值可以通过参数-XX:MaxTenuringThreshold设置。
大对象(很长的字符串和数组)直接进入老年代,虚拟机提供了一个参数-XX:PretenureSizeThreshold(晋升老年代对象大小),令大于设置值的对象直接在老年代中分配,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存复制。
当通过Full GC释放不出太多内存并且仍然有新对象不断进来,此时才会抛出OutOfMemoryError异常。
四、 虚拟机类加载机制
参考“日拱一兵”公众号:类加载机制和双亲委派模型
五、 Java内存模型与线程
1. Java内存模型(Java Memory Model,JMM)
Java内存模型是描述多线程的工作方式的一种规范,其主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的“变量”是指实例字段(对象)、静态字段(被static修饰的)和构成数组对象的元素等,不包括局部变量和方法参数(因为方法参数和方法内部定义的局部变量都存储在虚拟机栈的栈帧中,而虚拟机栈是“线程私有”的,不会被共享,不存在线程竞争的问题)。
Java内存模型规定了所有变量都存储在主内存(共享变量)中。每一条线程都有自己对应的一个工作内存,工作内存中存储被该线程使用到的变量的主内存副本拷贝(共享变量副本),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间的变量值的传递均需要通过主内存来完成。
内存间的交互操作:
关于主内存与工作内存之间具体的交互协议,即一个变量是如何从主内存拷贝到工作内存、又如何从工作内存同步回主内存,Java内存模型定义了如下的8大原子(不可再分的)操作。
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
load(载入):作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的变量值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将执行这个操作;
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
write(写入):作用于主内存的变量,它store操作从工作内存中得到的变量的值放入主内存的变量中。
执行上述8种操作的规则:
(1)如果要把一个变量从主内存复制到工作内存,那就要顺序执行read和load操作(注意是顺序执行而不一定是连续执行);如果要把一个变量从工作内存同步回主内存,那就要顺序执行store和write操作(注意是顺序执行而不一定是连续执行);
(2)不允许read和load操作、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现;
(3)不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存;
(4)不允许一个线程无原因地(没有发生过任何的assign操作)把数据从线程的工作内存同步回主内存中;
(5)一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或这assign)的变量,即对一个变量实施use或store操作之前,必须先执行过了load或assign操作;
(6)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock操作后,只有执行相同次数的unlock操作,变量才会被解锁;
(7)如果对一个变量执行lock操作,那么将会清空工作内存中该变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign操作初始化变量的值;
(8)如果一个变量事先没有被lock操作锁定,那么就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量;
(9)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
2. 关键字volatile
关键字volatile,是Java虚拟机提供的最轻量级的同步机制。
volatile变量的特性:
(1)“可见性”:保证此volatile变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
虽然volatile变量在各个线程的工作内存中不存在一致性的问题,但是由于Java里面的运算并非原子操作,导致volatile变量的运算在并发下仍然是不安全的。
由于volatile变量只能保证“可见性”而不能保证“原子性”(我们仍要通过使用synchronized关键字或者java.util.concurrent包中的原子类ReentrantLock加锁来保证原子性),因此volatile变量有自己的使用场景需求:一是运算结果并不依赖于变量的当前值或者能够确保只有单一线程修改变量的值,二是变量不需要与其他状态变量共同参与不变约束。
(2)禁止指令重排序优化:内存屏障(重排序时不能把后面的指令重排序到内存屏障之前的位置),保证变量的赋值顺序与程序代码的执行顺序一致。
3. Java内存模型的三大特性
3.1 原子性
由Java内存模型直接保证的原子性变量操作(主内存与工作内存之间的交互操作)有read、load、use、assign、store、write,对基本数据类型的访问读写都是原子的(例外就是long和double的非原子性协定);lock和unlock操作可以保证更大范围的原子性(尽管虚拟机未把lock和unlock操作直接开放给用户指用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块)——同步块(synchronized代码块)之间的操作也具备原子性。
3.2 可见性
什么叫“可见性”?
“可见性”是指当一个线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的(其他线程能够立即得知这个修改)。Java内存模型是通过在变量修改后将新值同步回主内存、在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现“可见性”的,普通变量和volatile变量都是如此,只不过普通变量和volatile变量的区别在于volatile变量的特殊规则保证了新值能够立即同步到主内存以及每次使用前立即从主内存刷新。
volatile关键字保证了多线程时变量操作的可见性。
synchronized关键字也可以保证多线程时变量操作的可见性(对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作))。
final关键字也可以保证多线程时变量操作的可见性(被final修饰的字段在构造器中一旦初始化完成并且构造器没有把“this“的引用传递出去,那么在其他线程中也能够看见这个final字段的值)。
3.3 有序性
volatile关键字能够保证线程之间操作的有序性(volatile具有禁止指令重排序优化的语义)。
synchronized关键字也能够保证线程之间操作的有序性(根据“一个变量在同一时刻只允许一条线程对其进行lock操作”的规则,说明持有同一个锁的两个同步块只能串行地进入)。
4. 先行发生原则
“先行发生原则”是指Java内存模型中定义的两项操作之间的偏序关系(如果说操作A先行发生于操作B,其实就是说在操作B发生之前,操作A产生的影响能被操作B观察到,这种影响包括修改了内存中共享变量的值、发送了消息、调用了方法)是判断数据是否存在竞争、线程是否安全的主要依据。
8种“先行发生关系”:
(1)程序次序规则:在一个线程内,按照程序代码的顺序(准确地说,应该是控制流的顺序而不是程序代码的顺序,因为要考虑分支循环等结构),书写在前面的代码先行发生于书写在后面的代码。
(2)管程锁定规则:一个unlock操作先行发生于后面(是指时间上的先后顺序)对同一个锁的lock操作。
(3)volatile变量规则:对一个volatile变量的写操作先行发生于后面(是指时间上的先后顺序)对这个变量的读操作。
(4)线程启动规则:Thread类的start()方法先行发生于此线程内的每一个动作。
(5)线程终止规则:线程中的所有操作均先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到此线程已经终止执行。
(6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
(7)对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于这个对象的finalize()方法的开始。
(8)传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么就可以得出操作A先行发生于操作C的结论。
注意:“时间上的先后顺序”与“先行发生原则”之间基本上没有太大关系,衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以“先行发生原则”为准。
5. 进程与线程
5.1 线程与进程的关系
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程的资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。
5.2 线程实现的三种方式
(1)使用内核线程实现(轻量级进程与内核线程之间1:1的关系———一对一的线程模型):内核线程(KLT)通过操纵线程调度器(Thread Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上;同时,程序(P)一般不会直接去使用内核线程(KLT),而是去使用内核线程的一种高级接口即轻量级进程(LWP,就是我们通常意义上所说的线程),每一个轻量级进程(LWP)都有一个内核线程(KLT)支持。
优点:由于每一个轻量级进程都有一个内核线程支持,因此每一个轻量级进程都可以作为一个独立的调度单元,即使一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作;
缺点:系统调用代价相对较高,需要在内核态和用户态之间来回切换;由于每一个轻量级进程都需要一个内核线程支持,因此会消耗一定的内核资源。
(2)使用用户线程实现(进程与用户线程之间的1:N的关系——一对多的线程模型)
(3)使用用户线程加轻量级进程混合实现(用户线程与轻量级进程之间N:M的关系——多对多的线程模型):用户线程(UT)还是完全建立在用户空间中(用户线程的建立、同步、销毁和调度等操作依然廉价,可以支持大规模的用户线程并发),轻量级进程(LWP)作为用户线程(UT)与内核线程(KLT)之间的桥梁,这样就可以使用内核线程的线程调度功能(线程调度器,Thread Scheduler)和处理器的映射功能(将线程的任务映射到各个处理器上)了。由于用户线程的调用需要轻量级进城来完成,大大降低了整个进程被阻塞的风险。
Java使用的线程调度方式是:抢占式调度(由系统来分配CPU执行时间)。
5.3 线程的状态
Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种线程状态。
(1)新建(New):创建后尚未启动的线程处于这种状态。
(2)运行(Runnable):Runnable包括了操作系统线程状态的Running和Ready,也就是处在这个状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
(3)无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们将等待着被其他线程显式地唤醒(通过notify()方法或者notifyAll()方法),能够让线程处于无限期等待状态的方法有:
没有设置timeout参数的Object.wait()方法;
没有设置timeout参数的Thread.join()方法;
LockSupport.park()方法。
(4)限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无需等待被其他线程显式地唤醒,在一定时间后它们会由系统自动唤醒,能够让线程处于限期等待状态的方法有:
Thread.sleep()方法;
设置timeout参数的Object.wait()方法;
设置timeout参数的Thread.join()方法;
LockSupport.parkNanos()方法;
LockSupport.parkUntil()方法;
(5)阻塞(Blocked):线程被阻塞了。“阻塞状态”与“等待状态”的区别在于——阻塞状态在等待着获取到一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生;等待状态则是等待一段时间或者唤醒动作的发生。在程序等待进入同步区域(synchronized)的时候,线程将进入这种状态。
(6)结束(Terminated):已终止线程的线程状态,线程已经结束执行。
六、 线程安全与锁优化
1. 线程安全
线程安全的定义:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以得到正确的结果,那这个对象就是线程安全的。
线程安全从强到弱可以分为:不可变,绝对线程安全,相对线程安全,线程兼容,线程对立。
1.1 不可变
不可变的对象一定是线程安全的,无论是对象的方法实现还是方法调用者,都不需要采取任何的线程安全保障措施(例如:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有将this的引用传递出去,那在其他线程中就可以看见final字段的值)。
如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的;
如果共享数据是一个对象,那就需要保证对象的行为对其状态不会产生任何影响,把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。(例如:String类)。
1.2 绝对线程安全
在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
例如:尽管java.util.Vector类中的add()方法、get()方法、remove()方法、size()方法等都是被synchronized关键字修饰的,但是在多线程的环境中,如果不在方法调用端做额外的同步措施(可以使用synchronized代码块,让多个线程持有同一个锁,根据先行发生原则中的“一个unlock操作先行发生于后面对同一个锁的lock操作,这里的“后面”是指时间上的先后顺序)的话,使用这些方法仍然是线程不安全的。
1.3 相对线程安全
相对线程安全就是我们通常意义上所说的线程安全,它需要保障对这个对象的单独操作是线程安全的,且在调用的时候不需要额外的保障措施(大部分线程安全的类都属于相对线程安全,例如:Vector类,Hashtable类)。但是对于一些有特定顺序的连续调用(例如Vector类中的remove()方法和get()方法),就可能需要在调用端进行额外的同步手段(例如synchronized代码块)来保证调用的正确性。
1.4 线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以被安全地使用。Java API中大部分的类(例如:ArrayList类,HashMap类)都是线程兼容的(我们通常所说的不是线程安全的类绝大多数是指这种情况)。
1.5 线程对立
线程对立是指无论在调用端是否采取了同步措施,都无法在多线程环境中并发地使用代码(例如:重新分配标准输入流的System.setIn()和重新分配标准输出流的System.setOut()方法)。
2. 线程安全的实现方法
2.1 互斥同步(阻塞同步)——悲观并发策略
互斥同步是常见的一种并发正确性保障手段。“同步”是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用。互斥实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现方式。互斥是因,同步是果;互斥是方法,同步是目的。
2.1.1 synchronized关键字(重量级同步机制)
最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令(虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字),这两个字节码指令都需要一个reference类型(引用数据类型)的参数来指明要锁定和解锁的对象。
(1)synchronized代码块
使用synchronized关键字修饰的普通代码块称为同步代码块。对于同步代码块而言,Java程序必须为它显式地指定同步监视器(可以是代表本类对象的引用的“this”关键字,也可以是自定义的继承自Object类的对象参数全局变量)。
(2)非静态的synchronized方法
使用synchronized关键字修饰的非静态方法(实例方法)称为非静态的同步方法。非静态的同步方法的同步监视器是“this”(表示本类对象的引用,即调用该方法的对象实例)。
(3)静态的synchronized方法
使用synchronized关键字修饰的静态方法(类方法,被static修饰)称为静态的同步方法。静态的同步方法的同步监视器是该类本身(Class对象实例,即运行时类信息)。Class对象实例(运行时类信息)可以通过调用Object类的非静态方法getClass()方法(通过this调用)获得,也可以通过静态加载.class获得。
注意:
(1)静态同步方法与非静态同步方法之间不存在竟态(竟态指计算结果的正确性依赖于相对时间顺序和线程交错)。因为静态同步方法与非静态同步方法持有的锁(同步监视器)不同,静态同步方法的同步监视器是类本身(Class对象实例,运行时类信息),而非静态同步方法的同步监视器是“this”关键字(表示本类对象的引用,即调用该方法的对象实例)。
(2)静态同步方法与同步代码块之间可能存在竟态。当同步代码块指定的同步监视器是代表本类对象的引用的“this”关键字或者是普通的自定义的继承自Object类的对象参数(非Class对象)全局变量时,静态同步方法与同步代码块之间不存在竟态。当同步代码块指定的同步监视器是类本身(Class对象实例,即运行时类信息,其可以通过this.getClass()或者类名.class获得)时,静态同步方法与同步代码块之间存在竟态。
public class SynchronizedDemo implements Runnable
{
public static boolean staticFlag = true;
public static synchronized void test0()//静态同步方法
{
for(int i = 0;i < 5;i++)
{
System.out.println("test0:"+Thread.currentThread().getName() + " "+ i);
}
}
public void test1()
{
//同步代码块
//synchronized (this)//不存在竟态
synchronized (SynchronizedDemo.class)//synchronized (this.getClass())//存在竟态
{
for(int i = 0;i < 5;i++)
{
System.out.println("test1:"+Thread.currentThread().getName() + " "+ i);
}
}
}
//复写Runnable接口中的run()方法
public void run()
{
if(staticFlag)
{
staticFlag = false;
test0();
}
else
{
staticFlag = true;
test1();
}
}
public static void main(String[] args) throws InterruptedException
{
SynchronizedDemo s = new SynchronizedDemo();
new Thread(s).start();//创建并启动线程
new Thread(s).start();//创建并启动线程
}
}
根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁(同步监视器),如果这个对象没有被锁定或者当前线程已经拥有了那个对象的锁,就会把锁的计数器加1;相应的,在执行monitorexit指令时,就会把锁计数器减1;当锁计数器为0时,锁就被释放了。如果获取对象的锁失败了,那当前线程就要阻塞等待(在程序等待进入同步区域的时候,线程将进入阻塞状态),直到对象的锁被另外一个线程释放为止。
注意:
(1)synchronized同步块对于同一条线程来说是可重入的(一个变量在同一时刻只能被一条线程执行lock操作,但是lock操作可以被同一条线程重复执行多次),不会出现自己把自己锁死的问题;
(2)同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入(对一个锁的unlock操作先行发生于后面对同一个锁的lock操作)。
2.1.2 ReentrantLock(重入锁)
我们还可以使用java.util.concurrent(J.U.C)包中的重入锁ReentrantLock来实现同步。
ReentrantLock与synchronized之间的区别和联系?
联系:ReentrantLock与synchronized都是以加锁的方式同步,而且都是阻塞式同步(同步块在已进入的线程执行完之前,会阻塞后面其他线程的的进入),进行线程阻塞和唤醒的代价是比较高的,因为操作系统需要在用户态和内核态之间来回切换。ReentrantLock与synchronized都具备“线程重入”特性。
区别:synchronized表现为原生语法层面上(因为synchronized是Java语言中的关键字,需要Java虚拟机来实现)的互斥锁;
ReentrantLock表现为API层面上的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),而且相比synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。
(1)等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以放弃等待,改为处理其他事情。
(2)可实现公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序以此获得锁。而非公平锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下(构造函数为public ReentrantLock())也是非公平的但可以使用带布尔值的构造函数(public ReentrantLock(boolean b))要求使用公平锁(ReentrantLock lock = new ReentrantLock(true);)。
(3)锁可以绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,通过多次调用newCondition()方法。ReentrantLock提供了一个Condition类(条件类),用来分组唤醒(通过Condition类中的signal()和signalAll()方法)需要被唤醒的线程们。而在synchronized中,Object类中的wait()方法和notify()(随机地唤醒任何一个正在等待锁的线程)、notifyAll()(唤醒所有正在等待锁的线程)方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁。
“生产者消费者模式”如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
//生产者消费者模式:遵从生产者生产一件产品,消费者就消费一件产品
class Service
{
//私有化,仅在本类中有效
private Lock lock = new ReentrantLock();//定义一个重入锁(表现为API层面上的互斥锁)//ReentrantLock默认是非公平锁
private Condition condition = lock.newCondition();//一个ReentrantLock对象可以同时绑定多个Condition对象,只需多次调用newCondition()方法即可
private boolean flag = false;
private int number = 0;//生产产品的编号,以此为衡量标志
//ReentrantLock是API层面上的互斥锁,通过lock()和unlock()方法配合try/finally语句块来完成
//生产者生产产品
public void produce()
{
try
{
lock.lock();//使用lock()方法加锁
while (flag == true)
{
condition.await();//没有指定timeout参数时,等待着被其他线程显式地唤醒
}
System.out.println(Thread.currentThread().getName() + "-----生产-----");
number++;
System.out.println("number: " + number);
flag = true;
condition.signalAll();//唤醒正在等待锁的所有线程,即提醒消费者消费
}
catch (InterruptedException e)
{
e.printStackTrace();
}
finally//一定会执行的代码,通常用于释放资源
{
lock.unlock();//使用unlock()方法释放锁
}
}
//消费者消费生产的产品
public void consume()
{
try
{
lock.lock();//使用lock()方法加锁
while (flag == false)
{
condition.await();//没有指定timeout参数时,等待着被其他线程显式地唤醒
}
System.out.println(Thread.currentThread().getName() + "-----消费-----");
number--;
System.out.println("number: " + number);
flag = false;
condition.signalAll();//唤醒正在等待锁的所有线程,提醒生产者生产
}
catch (InterruptedException e)
{
e.printStackTrace();
}
finally
{
lock.unlock();//通过unlock()方法释放锁
}
}
}
//生产者线程(定义一个类实现Runnable接口,并复写Runnable接口中的run()方法)
class MyThreadProduce implements Runnable
{
private Service service;
public MyThreadProduce(Service service)
{
this.service = service;//this关键字用于区分成员变量和局部变量同名的情况
}
public void run()
{
for (;;)//无限循环
{
service.produce();
}
}
}
//消费者线程(定义一个类实现Runnable接口,并复写Runnable接口中的run()方法)
class MyThreadConsume implements Runnable
{
private Service service;
public MyThreadConsume(Service service)
{
this.service = service;//this关键字用于区分成员变量和局部变量同名的情况
}
public void run()
{
for (;;) //无限循环
{
service.consume();
}
}
}
public class ProducerConsumerMode
{
public static void main(String[] args)
{
Service service = new Service();
new Thread(new MyThreadProduce(service), "生产者").start();//创建并启动线程
new Thread(new MyThreadConsume(service), "消费者").start();//创建并启动线程
}
}
“一个ReentrantLock同时绑定多个Condition对象”的示例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
//顺序执行线程
class Service1
{
// 通过nextThread控制下一个要执行的线程
private static int nextThread = 1;
private Lock lock = new ReentrantLock();
//一个ReentrantLock对象同时绑定多个Condition对象(锁绑定多个条件),通过多次调用newCondition()方法
Condition conditionA = lock.newCondition();
Condition conditionB = lock.newCondition();
Condition conditionC = lock.newCondition();
//ReentrantLock是API层面上的互斥锁(通过lock()和unlock()方法配合try/finally语句块来完成)
public void excuteA()
{
try
{
lock.lock();//通过lock()方法加锁
while (nextThread != 1)
{
conditionA.await();
}
System.out.println(Thread.currentThread().getName() + " 工作");
nextThread = 2;
conditionB.signalAll();//执行完线程A后显示地唤醒线程B
}
catch (InterruptedException e)
{
e.printStackTrace();
}
finally
{
lock.unlock();//通过unlock()方法释放锁
}
}
public void excuteB()
{
try
{
lock.lock();//通过lock()方法加锁
while (nextThread != 2)
{
conditionB.await();
}
System.out.println(Thread.currentThread().getName() + " 工作");
nextThread = 3;
conditionC.signalAll();//执行完线程B后显式地唤醒线程C
}
catch (InterruptedException e)
{
e.printStackTrace();
}
finally
{
lock.unlock();//通过unlock()方法释放锁
}
}
public void excuteC()
{
try
{
lock.lock();//通过lock()方法加锁
while (nextThread != 3)
{
conditionC.await();
}
System.out.println(Thread.currentThread().getName() + " 工作");
nextThread = 1;
conditionA.signalAll();//执行完线程C后显式地唤醒线程A
}
catch (InterruptedException e)
{
e.printStackTrace();
}
finally
{
lock.unlock();//通过unlock()方法释放锁
}
}
}
class ThreadA implements Runnable
{
private Service1 service;
public ThreadA(Service1 service)
{
this.service = service;
}
public void run()
{
for (;;) //无限循环
{
service.excuteA();
}
}
}
class ThreadB implements Runnable
{
private Service1 service;
public ThreadB(Service1 service)
{
this.service = service;
}
public void run()
{
for (;;) //无限循环
{
service.excuteB();
}
}
}
class ThreadC implements Runnable
{
private Service1 service;
public ThreadC(Service1 service)
{
this.service = service;
}
public void run()
{
for (;;) //无限循环
{
service.excuteC();
}
}
}
//线程按顺序执行
public class SequenceThread
{
public static void main(String[] args)
{
Service1 service = new Service1();
new Thread(new ThreadB(service), "B").start();
new Thread(new ThreadA(service), "A").start();
new Thread(new ThreadC(service), "C").start();
}
}
2.2 非阻塞同步——基于冲突检测的乐观并发策略(冲突检测->更新数据)
非阻塞同步:先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观并发策略的许多实现都不需要把线程挂起。
硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,常用的处理器指令有:测试并设置(Test-and-Set)、获取并增加(Fetch-and-Increment)、交换(Swap)、比较并交换(Compare-and-Swap,CAS)、加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)。
2.2.1 CAS操作(原子操作)
CAS操作需要有3个操作数,分别是内存位置(Java中可以简单地理解为变量的内存地址)V、旧的预期值A、新值B。 如果内存位置值V与旧的预期值A相匹配,那么处理器会自动将该内存位置值V更新为新值B ;否则,处理器不做任何操作。但是无论是否更新了内存位置V的值,它都会返回V的旧值(在 CAS 操作的一些特殊情况下将仅返回 CAS 是否成功即true或者false,而不提取当前值)。CAS 操作有效地说明了“我认为位置 V 应该包含值 A;如果包含值A,则将 值B放到这个位置V;否则,不要更改该位置V的值,只告诉我这个位置V现在的值即可。”
通常将 CAS操作用于同步的方式是从内存地址V中读取旧的预期值 A(例:A=get()),执行多步计算来获得新值 B(例如:B=A+1 ),然后使用 CAS操作(当内存地址值V与旧的预期值A相匹配时)将V的值从A改为B(V=B)。如果内存地址V处的值尚未同时更改(内存地址值V与旧的预期值A相匹配),则CAS 操作成功。
CAS操作可以防止内存中的共享变量出现“脏读脏写”的问题。类似于 CAS 的指令允许算法执行读-修改-写操作,而无需担心由于可能会有其他线程同时修改共享变量的值导致读取到过期的数据,因为如果其他线程修改了共享变量的值,那么CAS会检测它(并失败),算法可以对该操作重新执行。
在JDK1.5之后Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()等方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条与平台相关的CAS指令,没有方法调用的过程,可以认为是无条件地内联进去了。
//native方法(位于本地方法栈),是没有其Java代码实现的,而是需要依靠JDK和JVM的实现。
//CAS通过调用JNI(Java Native Interface为JAVA本地调用,允许java调用其他语言)的代码实现的。
//该方法就是Java调用C语言和汇编代码来实现的。
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int update);
此方法是Java的native方法(位于本地方法栈中),并不由Java语言实现,而是Java通过调用JNI(Java Native Interface为JAVA本地调用,允许Java调用其他语言)的代码实现的。该方法的作用是,读取传入对象o在内存中偏移量为offset位置的值(即内存位置或者说内存地址V)与期望值expected(旧的预期值A)作比较。如果相等(内存位置V与旧的预期值A相匹配)就把update(新值B)值赋值给offset位置的值(内存位置V),并返回true;如果不相等(说明该值在读->修改->写过程中已经被其他线程修改了,该值已经过期了),就取消赋值,并返回false。这也是CAS的思想,即比较并交换,用于保证并发时的无锁(不依赖JVM或者操作系统的锁机制)并发的安全性。
sun.misc.Unsafe类并没有提供给用户程序直接调用,因此如果不采用反射手段,我们只能通过其他的Java API来间接使用它,例如java.util.concurrent(J.U.C)包中的整数原子类AtomicInteger,其中的getAndIncrement()、incrementAndGet()、compareAndSet()等方法都使用了sun.misc.Unsafe类中的CAS操作。
//以原子的方式增加1,并返回旧值(加1之前的原始值)——i++的原子实现
public final int getAndIncrement()
{
for (;;) //无限循环(相当于自旋锁)
{
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
//以原子的方式增加1,并返回新值(加1之前后的新值)——++i的原子实现
public final int incrementAndGet()
{
for (;;) //无限循环(相当于自旋锁)
{
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
public final boolean compareAndSet(int expect, int update)
{
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
Java的CAS操作可以实现现代CPU上硬件级别的原子指令(不是依靠JVM或者操作系统的锁机制)保证原子性,而同时volatile关键字又保证了线程间共享变量的“可见性”(当一个线程修改了共享变量的值,新值对于其他线程来说也是可以立即得知的)和指令的顺序性(volatile关键字具有禁止指令重排序优化的语义)。因此凭借这两种手段(CAS操作和volatile关键字),就可以实现不依靠操作系统的锁机制来保证并发时共享变量的一致性。
如果我们仔细分析java.util.concurrent(J.U.C)包的源代码实现,就会发现一个通用化的实现模式:
(1)首先,将共享变量声明为volatile;
(2)使用CAS操作的原子条件更新来实现线程之间的同步;
(3)配合以volatile的读/写和CAS操作所具有的volatile读和写的内存语义来实现线程之间的通信。
CAS操作存在“ABA”问题:
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它的值仍然为A值,此时并不能说明它的值就没有被其他线程修改过,因为有可能在这段期间它的值曾经被改为了值B但后来又被改回了A,CAS操作就会误认为它从来没有被改变过。J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。
2.3 无同步方案
要保证线程安全,并不一定就要进行同步,线程安全与同步之间并没有因果关系。同步只是保证共享数据争用时的正确性的手段,但是如果一个方法本来就不涉及共享数据,那么它自然就不需要任何的同步措施去保证正确性,因此会有一些代码天生就是线程安全的,比如可重入代码和线程本地存储。
2.3.1 可重入代码(纯代码)
可重入代码(纯代码)可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。所有可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
判断代码是否具备可重入性的原则?
如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就一定能得到相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
如果一段代码或者一个函数是“可重入”的,则具有如下特征:
(1)不能含有静态(全局)的非常量(没有被final修饰)的数据(因为静态变量存储在方法区中,而方法区是线程共享的数据区);
(2)不能返回静态(全局)非常量数据的地址(对象实例存储在Java堆中,Java堆是线程共享的数据区);
(3)只能处理由调用者提供的数据(用到的状态量都由方法参数传入);
(4)不能调用不可重入的方法;
(5)不依赖单实例模式资源的锁。
注意:这里复习一下static(静态)关键字:
(1)static是一个修饰符,用于修饰类中的成员,包括成员变量和成员方法;
(2)static修饰的成员被所有对象所共享;
(3)static修饰的成员随类的加载而加载,随类的消失而消失,生命周期最长;
(4)static优先于对象存在;
(5)static修饰的成员多了一种调用方式,就是可以直接被类名所调用,即类名.静态成员;
(6)static修饰的数据是共享数据(例如:Person类中的country),对象中存储的是特有数据(例如:Person类中的name)。
“可重入”与“线程安全”这两个概念都关系到函数处理资源的方式,但是它们有一定的区别。
“可重入”影响函数的外部接口:在大多数情况下,将不可重入的函数改为可重入的函数,只需要修改函数的外部接口,使得函数内需要用的所有数据(状态量)都由函数调用者提供(通过方法参数传入)。
“线程安全”只关心函数的实现:如果要将非线程安全的函数改为线程安全的函数,只需要修改函数的实现部分,一般通过加入同步机制来保证共享数据争用时的正确性。
2.3.2 线程本地存储
如果一段代码所需要的数据必须与其他代码共享,那就看看这些操作共享数据的代码是否能保证在同一个线程中执行?如果能保证,就可以把共享数据的可见范围限制在同一个线程之内,这样无需同步也能保证线程之间不出现数据争用的问题。
在Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字(具有“保证此变量对所有线程的可见性”的特性)将其声明为“易变的”。但是,如果一个变量要被一个线程独享(将该变量的可见范围限制在同一个线程之内),可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。
2.3.3 java.lang.ThreadLocal类详解
ThreadLocal是一种变量类型,称为“线程局部变量”。意思是ThreadLocal中填充的变量属于当前线程,该变量对于其他线程而言是线程隔离的。每个线程访问这种变量的时候都会创建该变量的副本,这个变量副本为线程私有。ThreadLocal类型的变量一般使用private static 进行修饰以将该状态量关联到一个线程的类的私有静态字段。
ThreadLocal类中的基本方法:
返回值类型 | 方法定义 |
---|---|
void | set(T value):设置与当前线程关联的ThreadLocal的值 |
T | get():获取与当前线程关联的ThreadLocal的值 |
void | remove():将与当前线程关联的ThreadLocal删除,以减少内存的占用 |
T | initialValue():设置与当前线程关联的ThreadLocal的初始值 |
set方法的代码如下:
public void set(T value)
{
//首先获取当前线程Thread
Thread t = Thread.currentThread();
//调用getMap方法获取当前线程的ThreadLocalMap对象(每个线程Thread都维护着一个ThreadLocalMap映射表)
ThreadLocalMap map = getMap(t);
if(map != null)
//如果map不为空,则设置值
map.set(this,value);//this是键(代表这个ThreadLocal类型的变量或者说是对象),value是指定的要存储的值
else
createMap(t,value);//如果map为空,则创建一个当前线程所关联的ThreadThreadLocalMap对象
}
ThreadLocalMap getMap(Thread t)
{
//返回线程Thread的threadLocals变量,它的类型是ThreadLocal.ThreadLocalMap(ThreadLocalMap是ThreadLocal的静态内部类)
return t.threadLocals;
}
void creatMap(Thread t,T firstValue)
{
t.threadLocals = new ThreadLocalMap(this,firstValue);
}
ThreadLocalMap的构造函数的代码如下:
ThreadLocalMap(ThreadLocal<?> firstKey,Object firstValue)
{
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY -
1);
table[i] = new Entry(firstKey,firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
Class ThreadLocal
{
//......
static class ThreadLocalMap//静态内部类
{
//map中的每个节点Entry,其键key是ThreadLocal并且还是弱引用,这也导致了后续会产生内存泄漏问题。
static class Entry extends WeakReference<ThreadLocal<?>>//静态内部类
{
Object value;
Entry(ThreadLocal<?> k, Object v)
{
super(k);//访问父类的构造方法
value = v;
}
}
//初始化容量为16,以后对其扩充也必须是2的指数
private static final int INITIAL_CAPACITY = 16;
//真正用于存储线程的每个ThreadLocal的数组,将ThreadLocal和其对应的值以“键值对”的形式包装为一个Entry
private Entry[] table;
//......其他的方法和操作都和map的类似
}
}
可以看到ThreadLocalMap底层是一个初始大小为16的数组,数组中的元素类型是Entry类型。Entry对象用来保存每一个键值对(key-value),这里的key是ThreadLocal对象,value是真正需要存储的变量(我们定义的)。该数组是以ThreadLocal的hashCode&数组长度作为索引。每一个ThreadLocal对象都有一个hashCode值threadLocalHashCode,每初始化一个ThreadLocal对象,hashCode就增加一个固定的大小0x61c88647。
也就是说,每个线程Thread都维护了一个ThreadLocal.ThreadLocalMap类型的对象,而set操作其实就是以ThreadLocal变量为key,以我们指定的值为value,最后将这个"键值对"封装成Entry对象放到该线程的ThreadLocal.ThreadLocalMap对象中。每个ThreadLocal变量在该线程中都是ThreadLocal.ThreadLocalMap对象中的一个Entry元素。既然每个ThreadLocal变量都对应ThreadLocal.ThreadLocalMap(底层是一个数组)中的一个元素(元素索引的计算方法为ThreadLocal的hashCode&数组长度),那么就可以对这些元素进行读、写和删除操作了。
get方法的代码如下:
public T get()
{
//首先获取当前线程Thread
Thread t = Thread.currentThread();
//调用getMap方法获取当前线程的ThreadLocalMap对象(每个线程Thread都维护着一个ThreadLocalMap映射表)
ThreadLocalMap map = getMap(t);
if (map != null)//如果Thread的ThreadLocalMap对象不为空,则调用其getEntry方法获取以此ThreadLocal作为键的Entry。
{
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value;
}
return setInitialValue();//如果Thread的ThreadLocalMap对象为空,则设置一个初始值(在内部调用initialValue()方法)
}
在设置值(set)的过程中,根据ThreadLocal对象的hashCode值可以计算出相应的数组索引从而定位到table数组中的位置i,过程如下:1、如果当前位置是空的,那么正好,就初始化一个Entry对象放在位置i上;2、很不巧,位置i已经有了一个Entry对象了,如果这个Entry对象的key正好是即将设置的key,那么重新设置此Entry中的value; 3、很不巧,位置i的Entry对象中保存的键不是即将设置的key,那么只能找下一个空位置(注:即使不同的hashCode值,也有可能映射到数组的相同位置)。
在获取值(get)的时候,根据ThreadLocal对象的hashCode值可以计算出相应的数组索引从而定位到table数组中的位置i,然后判断该位置的Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。
ThreadLocal类的实现原理:
每个线程Thread维护着一个ThreadLocalMap(ThreadLocalMap是ThreadLocal的静态内部类,通过Entry进行存储,Entry是ThreadLocalMap的静态内部类)映射表(内部的元素以“键值对”的形式存储),这个映射表的key(“键”)是ThreadLocal对象实例自身,而Value(“值”)才是真正需要存储的变量(自己设定的)。也就是说,ThreadLocal本身并不存储值,它只是作为一个key来让线程Thread从ThreadLocalMap映射表中获取value。注意,ThreadLocalMap是使用ThreadLocal的弱引用(WeakReference)作为key的。由于被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作的时候,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。如果一个ThreadLocal没有外部关联的强引用,那么在虚拟机进行垃圾收集时,这个ThreadLocal会被回收掉,此时ThreadLocalMap中就会出现key为null的Entry,导致这些key对应的value也就无法被访问,但是此时的ThreadLocalMap的生命周期与Thread一样,它不会被回收掉,这样就会造成内存泄漏。内存泄漏的解决办法是使用完ThreadLocal后要执行remove()操作,避免出现内存泄漏的情况。
ThreadLocal中的内存泄漏问题:
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么当系统进行垃圾收集的时候,这个ThreadLocal就会被回收掉。这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry中存储的value,如果当前线程再迟迟不结束的话,这些key为null的Entry中存储的value就会一直存在一条强引用链:Thread对象的引用(t)->Thread对象(Thread.currentThread())->ThreadLocalMap(getMap(t))->Entry-value就永远无法被回收掉,造成内存泄漏。其实,ThreadLocalMap的设计中已经考虑到了这种情况,也加上了一些防护措施:在ThreadLocal类中的get(),set(),remove()的时候都会清除线程的ThreadLocalMap里所有key为null对应的value。
使用static来修饰ThreadLocal,延长了ThreadLocal的生命周期。如果分配使用了ThreadLocal但是却没有调用get(),set(),remove()方法,那么就会导致内存泄漏。
为什么要使用ThreadLocal的弱引用而不是强引用?
如果key 使用ThreadLocal的强引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
如果key 使用ThreadLocal的弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收(被弱引用关联的对象只能存活到下一次垃圾收集之前,在垃圾收集器工作时,不论当前内存是否足够,都会回收掉被弱引用关联的对象)。所有key为null对应的value在下一次ThreadLocalMap调用set, get,remove方法的时候会被清除。
3. 锁优化
锁优化技术有自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,这些锁优化技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
3.1 自旋锁与自适应自旋
由于互斥同步(互斥锁,以阻塞方式实现的同步,是悲观的并发策略)对性能最大的影响就是阻塞的实现,挂起线程和恢复线程的操作都需要转入到内核态中完成(频繁地进行用户态和内核态的切换),因此状态转换需要消耗很多的处理器时间。在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,因此如果物理机器有一个以上的处理器(多CPU),就能让两个或者两个以上的线程同时并行执行,这时就可以让后面请求锁的线程“稍等一下”,但不放弃处理器的执行时间,而是让线程执行一个忙循环(自旋),看看持有锁的线程是否很快就会释放锁,这项技术就是所谓的“自旋锁”。
自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用参数-XX:+UseSpinning来开启;在JDK 1.6中就已经改为默认开启了。
自旋等待并不能代替阻塞,主要原因有2点:一是自旋等待对处理器的数量有要求(当物理机器有一个以上的处理器时);二是自旋等待本身虽然避免了线程切换的开销,但是它是要占用处理器时间的,因此,如果锁被占用的时间很短,那么自旋等待的效果就会非常好,反之,如果锁被占用的事件很长,那么自旋的过程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,如果自旋超过了限定的次数(自选次数默认为10次,可以通过参数-XX:PreBlockSpin来更改)仍然没有成功获得锁,这时候就应当使用传统的方式挂起线程了。
在JDK 1.6中引入了“自适应自旋”,自适应意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态决定。
3.2 锁消除
“锁消除”是指虚拟机即时编译器在运行时,对于一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于“逃逸分析的数据支持”:如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那么就可以把它们当作栈上的数据来对待,认为它们是线程私有的,同步加锁自然也就无须进行。
许多同步措施并不是程序员自己加入的,例如Vector类中的add()、get()、remove()、size()等方法本身就是被synchronized修饰的同步方法,StringBuffer类中的append()方法也是被synchronized修饰的同步方法。
public String concatString(String s1,String s2,String s3)
{
return s1+s2+s3;
}
由于String是一个不可变的类(被final修饰,不可变的对象一定是线程安全的),对字符串的连接总是通过生成新的String对象来进行(返回一个新的字符串),因此Javac编译器总是会对String连接作自动优化。在JDK 1.5版本之前,会将其转化为StringBuffer类(相对线程安全)的连续append()操作,在JDK 1.5及以后的版本中,会将其转化为StringBuilder类(线程兼容)的连续append()操作。
public String concatString(String s1,String s2,String s3)
{
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个StringBuffer类的append()方法中都有一个同步块(append()方法是被synchronized修饰的非静态同步方法,所以它的同步监视器是表示本类对象的引用的“this”即调用该方法的对象实例),所以锁是sb对象。虚拟机观察变量sb,发现它的动态作用域被限制在concatString()方法内部,也就是说sb的所有引用永远不会逃逸到concatString()方法外部,其他线程也就无法访问到它(sb这个引用是局部变量,是虚拟机栈私有的,所以sb不是可以共享的资源)。因此这里虽然有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
3.3 锁粗化
当一系列的连续操作都对同一个对象进行反复加锁和解锁(如上3.2中的代码),甚至加锁操作是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能消耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁(如上3.2中的代码),将会把加锁同步的范围扩展(粗化)到整个操作序列的外部(如上3.2中的代码,就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了)。
3.4 轻量级锁
轻量级锁是在JDK 1.6中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁机制而言的,因此传统锁机制就称为“重量级锁”。
“轻量级锁”并不是用来代替“重量级锁”的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量所产生的性能消耗。如果不存在锁竞争,轻量级锁使用CAS操作避免了使用互斥量的开销。如果存在锁竞争,除了互斥量的开销(轻量级锁要膨胀为重量级锁)以外,还额外发生了CAS操作,此时轻量级锁会比传统的重量级锁更慢。
现在先回顾一下对象的内存布局,对象的内存布局包括对象头(Object Header)、实例数据和对齐填充。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,“Mark World”被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象的状态(未锁定、轻量级锁定、重量级锁定、GC标记、可偏向)来复用自己的存储空间。
对于非数组类型的对象,对象头(Object Header)中包含两部分信息:
(1)存储对象自身的运行时数据(对象哈希码值、GC分代年龄或者叫对象分代年龄、锁状态标志或者叫锁标志位、线程持有的锁、偏向线程ID、偏向时间戳等)即“Mark Word”(“Mark Word”是实现轻量级锁和偏向锁的关键)。HotSpot虚拟机的对象头Mark Word如下:
存储内容 | 锁状态标志 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 重量级锁定(膨胀) |
空(不需要记录信息) | 11 | GC标记 |
对象分代年龄,偏向线程ID,偏向时间戳 | 01 | 可偏向(偏向锁) |
其中“对象分代年龄”是指:对象经历过几次GC,它的年龄就是几。
(2)存储指向方法区对象所属类型数据的指针(类型指针),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是属于哪个类的实例。
对于数组类型的对象,对象头(Object Header)中包含三部分信息:
(1)存储对象自身的运行时数据;
(2)存储指向方法区中对象所属类型数据的指针(类型指针);
(3)存储数组的长度。
轻量级锁的执行过程:
(1)加锁过程
当线程1进入(访问)同步块的时候,如果此同步对象没有被锁定(处于“未锁定”的状态,锁标志位为“01”),虚拟机首先将在当前线程的栈帧(栈帧位于虚拟机栈中)中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象(对象存储在Java堆上)目前的Mark Word拷贝(Displaced Mark Word),此时锁对象的Mark Word中存储的内容是对象哈希码和对象分代年龄。然后,虚拟机将使用CAS操作(原子操作)尝试将对象的Mark Word更新为指向锁记录的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,表示此对象处于“轻量级锁定”状态,之后线程就可以进入同步块中继续执行。如果这个更新动作失败了,虚拟机会首先检查对象的Mark Word是否指向当前线程的栈帧,如果对象的Mark Word已经指向了其他线程的栈帧,则说明这个锁对象已经被其他线程抢占了,此时该线程将会通过自旋(执行忙循环)来尝试获取锁。如果有两条以上(>=2)线程的线程在争用同一个锁,那轻量级锁就不再有效,要膨胀为“重量级锁”,此时锁标志位为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待的线程也要进入阻塞状态。
PS:默认情况下自旋的次数是10次,可以通过参数-XX:PreBlockSpin来修改,或者自旋线程数超过了CPU核数的一半。在JDK1.6之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而他将允许自旋等待持续相对更长的时间。如果对于某个锁,自选很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋的过程,直接阻塞线程,避免浪费处理器资源。满足这两种情况之一后就会升级为重量级锁。
(2)解锁过程
如果此时对象的Mark Word仍然指向着线程的锁记录(锁记录是位于虚拟机栈的栈帧中的一块空间),那就用CAS操作(原子操作)把对象当前的Mark Word和线程中复制的Displaced Mark World替换回来,如果替换成功(没有被其他线程修改过),整个同步过程就完成了。如果替换失败(被其他线程修改过),说明有其他线程尝试获取过该锁,那就要在释放锁的同时,唤醒被挂起的线程,从而使得被唤醒的线程能够重新争夺锁来访问同步块。
3.5 偏向锁
偏向锁是JDK 1.6中引入的一项新型锁机制,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。偏向锁会偏向于第一个获得它的线程,如果在接下来的过程中该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。偏向锁可以提高带有同步但无竞争的程序性能,如果程序中大多数的锁总是被多个不同的线程访问(例如高并发场景下),那偏向模式就是多余的,可以使用参数-XX:-UseBiasedLocking来禁止偏向锁优化(此时程序默认进入轻量级锁定状态)来提升性能。
如果说轻量级锁是在无竞争情况下使用CAS操作(原子操作)消除同步使用的互斥量(重量级锁),那么偏向锁就是在无竞争情况下把整个同步都消除掉(连CAS操作也不做了)。
偏向锁的逻辑:
(1)当线程A第一次访问(进入)同步块时,首先检查对象头的Mark Word中的锁标志位是否为“01”,依此判断此时锁对象是否处于“偏向锁(可偏向)”状态或者“未锁定”状态。
(2)然后判断偏向锁标志位是否为1。如果是,则说明偏向锁可用,进入下一步流程;如果不是,则说明偏向锁不可用,进入轻量级锁逻辑(使用CAS竞争锁)。
(3)当偏向锁可用时,检查对象头的Mark Word中记录的thread ID是否是当前线程ID。如果是,则表明当前线程已经获得对象锁,以后该线程再进入同步块时,不需要CAS进行加锁,只会往当前线程的栈帧中添加一条Displaced Mark Word为空(Null)的锁记录(Lock Record),用来统计重入的次数。退出同步块释放偏向锁时,则会依次删除对应Lock Record,但是不会修改对象头的Mark Word中的thread ID。(如果是同一个线程加锁的时候,不需要争用即CAS竞争锁,只需要判断线程指针是否是同一个,可直接执行同步代码块)
(4)当偏向锁可用时,检查对象头的Mark Word中记录的thread ID是否是当前线程ID。如果不是,则进行CAS操作(原子操作)企图把当前线程ID记录在(替换到)对象头的Mark Word中。如果此时锁对象处于匿名偏向锁状态(已偏向的、未锁定的对象),则会替换成功,将对象头的Mark Word中的thread ID由0改成当前线程ID,同时将对象头中的Mark Word中的线程ID指向当前线程。然后在当前线程的栈帧中找到内存地址最高的可用的Lock Record,将当前线程ID存入,获取到锁,执行同步块。(如果成功Mark World则存储当前线程ID,接着执行同步块)
(5)如果锁对象已经被其他线程占用(对于线程B而言,线程A获取到了这个锁),则会替换失败,开始进行偏向锁撤销(这也是偏向锁的特点,一旦出现线程竞争,就会撤销偏向锁)。(如果已经有其他线程获得了偏向锁,这种情况说明存在锁竞争,需要撤销已获得偏向锁的线程即撤销偏向锁,并且把它持有的锁升级为轻量级锁,这个操作需要等到全局安全点也就是没有线程在执行字节码才能执行)。
(6)偏向锁的撤销需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的,没有字节码运行),暂停持有偏向锁的线程(Stop The World),检查持有偏向锁的线程是否存活(遍历当前虚拟机中的所有线程,如果能找到,则说明偏向的线程还存活),如果线程还存活,则检查线程A是否在执行同步块中的代码(是否继续竞争锁),如果是,则直接升级为轻量级锁,进行CAS竞争锁;如果不是,则将锁设置为无锁状态(未被锁定的、不可偏向的状态),然后再进一步升级为轻量级锁,进行CAS竞争锁。
PS:每次进入同步块(即执行monitorenter字节码指令)时,都会以从高往低的顺序在虚拟机栈中找到第一个可用的Lock Record,并设置偏向线程ID;每次解锁(即执行monitorexit字节码指令)时都会从最低的一个Lock Record开始移除。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步块中的代码。
(7)如果持有偏向锁的线程未存活,则进行校验是否允许重偏向。如果允许重偏向,则将Mark Word设置为匿名偏向锁状态(未锁定、未偏向但是可偏向的状态),CAS将偏向锁重新指向线程A(在对象头的Mark Word中和当前线程的栈帧的Lock Record中存储当前线程ID)。
(8)唤醒暂停的线程(恢复线程),从安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的)开始继续执行代码。
PS:“偏向锁撤销”是指在获取偏向锁的过程中因不满足条件导致需要将锁对象改为非偏向锁状态,而“偏向锁释放”则是指退出同步块的过程。
在开发中绝大部分情况下,一定会存在2个以上的线程竞争,那么如果开启偏向锁,反而会获取锁的资源消耗。所以可以通过JVM参数-XX:-UseBiasedLocking来禁止偏向锁优化。
3.6 重量级锁
重量级锁需要向操作系统申请资源,即从用户态(3级特权级)切换到内核态(0级特权级),此时线程会挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间。
Java中的每个对象都关联了一个监视器锁monitor,当monitor被占用时就会处于锁定状态。线程在执行monitorenter字节码指令时就是在尝试获取monitor的所有权,过程如下:
(1)如果monitor的进入数为0,则该线程可以进入monitor,然后将monitor的进入数设置为1,该线程即为monitor的所有者;
(2)如果线程已占有该monitor,只是重新进入该monitor,则再次进入该monitor后会将该monitor的进入数+1;
(3)如果其他线程已占有了该monitor,则线程会进入阻塞状态直到该monitor的进入数为0,再重新尝试获取该monitor的所有权。
monitor是可重入的,monitor是非公平锁。
monitor依赖操作系统的互斥锁来实现,线程被阻塞后就进入内核态,这会导致系统在用户态和内核态之间来回切换,严重影响锁的性能。我们应尽量在用户态就把加锁问题解决掉,以避免进入内核态的线程阻塞。
参考自周志明老师的《深入理解Java虚拟机》,个人感觉这本书还是很不错的,每次阅读都会有新的理解,推荐给大家,“书读百遍,其意自现”。除此之外,公众号“日拱一兵”也是我非常喜欢的一个技术公众号,讲解内容深入浅出,值得推荐。Java学习之路,“路漫漫其修远兮,吾将上下而求索”,欢迎大家批评指正,学习交流。