**
深入理解JVM虚拟机
**
一、垃圾收集器与内存分配
1. 1 相关名词与问题解决方案
(1)如何解决扫描时的对象消失?
- 增量更新
当黑色的对象插入一个新的指向白色对象的引用关系的时候,将这个引用关系记录起来,等所有并发扫描完以后,再将这些记录里的引用关系里的黑色对象为根,重新扫描一次(简单理解就是插入新关系后先不管,等到后面并发扫描结束后再对这些新插入的引用关系再扫描一次)
- 原始快照
当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发结束后,再对这些记录过的引用关系的灰色为根,重新扫描一次(简单理解就是删除新关系后先不管,等到后面并发扫描结束后再对这些引用关系再扫描一次)
(2)几种垃圾收集算法
- 标记清除-算法
存在两种机制:
第一种是标记所有需要回收的对象,然后统一回收。
第二钟是标记存活的对象,回收未被标记的对象。
缺点:
不稳定:随着对象的增多效率会变低
空间碎片化问题:一旦在内控空间中需要较大空间,有可能无法找到连续的空间
- 标记-复制算法
- 标记-整理算法
(3)根结点枚举是怎样完成的?
通过一组叫OopMap的数据结构。一旦类加载动作完成的时候,HotSpot就会把对象内的所有偏移量上的类型计算出来(如果某个类是这个对象的成员,那么就说明他们之间是有引用关系的)
(4)安全点
使用OopMap这个概念以后,可以完成GCRoot的灭局,但是如果很多指令都有可能生成OopMap,如果为每条指令都生成的话,显然太浪费内存空间了。所有引出一个叫安全点的东西,虚拟机只有在安全点才会停下来进行回收节点枚举。常见的安全点有:方法调用、循环跳转、异常跳转等。
如何让GC进行收集时所有线程都跑到最近的安全点?
抢先式中断:GC时,让所有在安全点上的线程挂起,不在安全点上的线程就恢复它,
让它跑到安全点以后再中断。
主动式抢断:为线程设置标志位,到达安全点的时候去轮询这个标志位,另外,在
内存分配和创建对象的地方也要进行轮询问,这是为了防止没有足够内存进行分配。
(5)安全区域
有时候,某个线程被挂起了,但是它并没有在安全点挂起,难道就要所有线程都等着它吗(事实上也等不到,为什么?素有线程都被挂起了,没有激活线程)
安全区域:扩大的安全点,在这个区域代表引用关系是不会发生变化的。当用户线程执行安全区域的代码时,首先标志自己进入了安全区域,这样的话GC时,虚拟机是不会去管这些在安全区域内的线程的;而当线程离开安全区域的时候,会先检查虚拟机是否已经完成了根结点枚举,如果完成了,这个线程就当没事发生一样离开安全区域,如果虚拟机没有完成根结点枚举,那么该线程就暂时不能离开安全区域,只能等待虚拟机完成根结点枚举。
2. 经典垃圾收集器
-
Serial收集器
-
ParNew收集器
-
Parallel Scavenge收集器
-
Serial Old收集器
-
Parallel Old收集器
-
CMS收集器
特点:以获取最短停顿时间为目的的收集器,基于标记-清除算法(Mark Sweep),通常用于B/S系统的服务器。
四个阶段:
1)初始标记
根结点枚举,必须停顿
2)并发标记
GC Root完成以后,对接下来的对象进行遍历,此时是和用户线程并发运行的。
3)重新标记
在并发标记期间,对象是有可能变动的,此时需要对这些修正过得对象进行重新标记。
4)并发清除
清除已经死亡的对象,此时是和用户线程并发进行的。
缺点:
1)对处理机资源敏感,在核心数低的时候很鸡肋
2)无法处理浮动垃圾
怎样理解?由于在并发标记和并发清除的阶段,用户线程仍然是在并发运行的,也就是不断会有新的垃圾产生,但是CMS并不能在当次几种清理它们,知到堆积到老年代完全满的时候再进行收集,此时会出现Fu’ll GC的出现。
3)无法处理碎片化问题
当不能找到连续的空间分配内存的时候就会引发一次Full GC。
-
G1收集器
特点:G1是一款面向服务端的收集器。
Mixed GC:不再固定新生代和老年代。G1是按照每个Region回收的价值区进行垃圾回收的,挑选价值最高的Region进行回收,每一个Region都可以根据自己的需要去划分Eden、Survivor空间或者老年代空间。在整体上看G1是基于标记-整理算法实现的。
问题:
1)如何解决Region的跨区域引用问题?
使用双向卡表,由于双向卡表是一个双向结构,因此这个卡表占用的空间比其他传统收集器更大。
2)如何解决在并发标记的时候对象的改变?
采用原始快照(SATB)。
3)怎样建立可靠的停顿时间预测模型?
采用“衰减均值”,在垃圾回收的时候,G1记录每个Region的回收耗时,每个Region记忆集的脏卡数量和各个可以预测的步骤所需要花费的成本,并分析得出平均值、标准差、置信度等统计信息。
步骤:
1)初始标记
2)并发标记
3)最终标记
此时用户线程要做一个短暂的暂停,用于处理并发阶段结束后遗留的最后那些少量的SATB记录。
4)筛选回收
把Region里存活的对象复制到另一片空的Region中去,再清理掉原来的Region区域,此时设计到对象的移动,必须暂停用户线程。
3. 低延迟垃圾回收器
-
Shenandoan收集器
-
相比起G1的改进:
1)支持并发的整理
G1的并发回收是不能和用户线程并发的,Shenandoan支持与用户线程并发进行
2)没有实现分代收集
3)抛弃双向卡表
使用连接矩阵,(第几行的region,第几行的region)
-
阶段:
-
-
ZGC收集器
特点:ZGC也是采用Region布局的,分为小型Region、中型Region、大型Region。ZGC的标志技术就是染色体指针技术。
-
染色体指针
在以前,保存对象的一些额外信息是在对象的对象头上面加的,存在的问题是:如果对象并不能访问成功(比如移动过该对象),或者说需要得到一些根本不需要去访问的对象的某些信息。事实上,在对收集算法的标记阶段就是只需要跟指针打交道而不需要跟对象打交道的一种应用场景。
染色体指针是一种非常直接的方式,直接将标记信息写在引用对象的指针上。目前AMD64架构最多支持48位的地址空间,OS也会对物理地址空间施加约束,Linux64位最大的寻址空间为47位(128TB)的虚拟地址空间和46位(64TB)的物理地址空间,windows甚至只支持44位(16TB)的物理地址空间。
染色体指针技术将Linux的46位的高4位拿出来存储四个标志信息,通过这些标志信息可以看到引用对象的三色信息。由于只剩42位,所以ZGC管理的堆内存不可能大于4TB(42位)
- 染色体指针不支持32位机,不支持压缩指针。它的优点有以下:
- 一旦某个Region的存活对象被移走以后,这个Region就能马上进行回收,为什么?因为传统的卡表和连接矩阵还要修改指向这片Region的那些引用,而ZGC的每个对象只与自己的对象指针有关,和别人是没有建立关系的,因此是可以直接进行回收的。
- 染色指针大幅度减少内存屏障的使用数量。
- 由于Linux还剩下18位没有用,因此染色指针的可扩展性是比较强的。
- 染色体指针不支持32位机,不支持压缩指针。它的优点有以下:
阶段:
1)并发标记
2)并发预备重分配
3)并发重分配
4)并发重映射
-
二、调优案例
1. 如何选择合适的收集器
(1)有预算但没有足够的调优经验
Zing VM C4收集器
(2)没有足够的预算。但能掌控软硬件版本
ZGC
(3)若对ZGC比较担心或者必须在Windows下使用
Shenandoah
(4)软硬件都比较落后
小内存考虑CMS,大内存可以用G1
常见的JVM命令:-XX:+heapDumpOnOutMemoryError
2. 大内存硬件上的程序步数策略
-
通过一个一个单独的Java虚拟机来管理大量的Java堆内存,但是前提必须是要控制好Full GC的频率,因为大内存一旦Full GC起来是一个很慢的过程。
-
同时使用若干个Java虚拟机,建立逻辑群来利用硬件资源。
缺点:
- 竞争全局资源,容易导致I/O异常
- 很难高效利用某些池
- 大量使用本地缓存—-
3. 集群间同步导致内存溢出
集群的各个节点之间的交互非常频繁,当网络不能满足要求的时候,重发的数据不断地在内存中堆积,很快就导致内存溢出。
4. 堆外内存导致的溢出
注意。堆外内存(指的是操作系统分配给虚拟机进程的大小-堆的大小),虚拟机虽然也会对这一部分空间进行回收,但是并不是像新生代、老年代那样主动回收的,而是发现空间不足了主动通知收集器进行垃圾回收,只能等待老年代满以后的Full GC出现以后顺便清理。所以有时候某些框架会大量的使用直接内存从而导致内存溢出。
5. 外部命令导致系统缓慢
如果频繁的使用一些要消耗大量堆内存的进程,纵然很快退出,但要是调用的数量非常大,那么就会消耗大量内存资源。
6. 服务器虚拟机进程崩溃
有一个场景:若服务器和客户机采用异步的调用方式,加入服务器的处理速度跟不上客户端的请求速度,暗恶魔就会堆积大量等待的线程和Socket,最终超过虚拟机所能承受的范围。
7.不恰当的数据结构导致内存占用过大
如果使用了不恰当的数据结构,导致这些对象全部都是不可清除的(活的),那么在复制的时候就会产生很大的负担,垃圾收集的时间就会明显变长。例如HashMap就是一个效率很低的数据结构。
8. 由Windows虚拟内存导致的长时间停顿
虚拟内存的作用就是把那些不常用的进程调出到磁盘上,若有一个GUI进程,采用心跳机制,隔一段时间才会启动一次,那么在大部分时间它是休眠的。这时候操作系统就很可能在它休眠期间把这个进程调出到外存上,所以每次进程重启的时候又会进行一次页面调换来回复之前的进程,这样的话在发生垃圾收集的时候很可能就会为了恢复页面而导致不正常的垃圾收集。
9. 由安全点导致的长时间停顿
查看安全点日志:-XX:+PrintSafepointStatistics 和-XX: PrintSafepointStatisticsCount=1
找出那些特别慢的进程,如何找出?添加-XX: SafepointTimeout和-XX: SafepointTimeDelay=2000(2000指的是超过就会认为超时,这样就会输出特别慢的线程的名称)。
HostSpot会把循环调用、方法调用、异常跳转都认为是安全点,因此,为了防止安全点的数量过多,有一种优化措施:使用Int或者更小的数据类型作为索引的循环是不会使用安全点的,只有使用那些Long或者范围更大的数据类型作为索引才会被设置为安全点。通常情况下这种优化是可行的,但是想一想。循环的时间仅仅是由循环次数决定吗?如果每一次循环的时间都很长,那么数量小的循环次数也需要较长的时间。
所以,有时候,虽然循环的索引结点时一个int类型的数据,但是由于每次循环的时间过长,就会导致Hotspot不会在那里插入安全点,当垃圾收集时,如果刚好执行到那里,那么就需要等到这个循环执行完才能进行垃圾收集。
解决的方案很简单,就是把循环的索引值改为Long即可。
三、类文件结构
字节码是Java平台无关性的基石,也是虚拟机实现语言无关性的基石。
1. Class类文件的结构
(1)magic魔术
(2)minor_version次版本号
(3)major_version主版本号
(4)常量池
Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。
(5)访问标志
这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等
(6)类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合
(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。
(7)字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。读者可以回忆一下在Java语言中描述一个字段可以包含哪些信息。字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。
(8)方法表集合
Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。
(9)属性表集合
- Code属性
- Exceptions属性
-
LineNumberTable属性
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。
……还有等等属性
2. 字节码指令
i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。
也有一些指令的助记符中没有明确指明操作类型的字母,例如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如无条件跳转指令goto则是与数据类型无关的指令。
大多数情况下,对理boolean、byte、short和char类型数据的操作都会转换成int类型数据的操作。
(1)加载和存储指令
- 将一个局部变量加载到操作栈:iload、iload_、lload、lload_、fload、fload_、dload、
dload_、aload、aload_ - 将一个数值从操作数栈存储到局部变量表:istore、istore_、lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、astore_
- 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_
- 扩充局部变量表的访问索引的指令:wide
(2)运算指令
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
- 局部变量自增指令:iinc
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
(3)类型转换指令
(4)对象创建与访问指令
(5)操作数栈管理指令
(6)控制转移指令
(7)方法调用和返回指令
(8)异常处理指令
(9)同步指令
四、类加载过程
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载
(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化
(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示。
在准备阶段和初始化阶段都会对变量进行赋值,准备阶段进行的是赋变量的零值,而初始化阶段是设置初始化值。
由于类的字段变量有两次的赋值过程(第一次在准备阶段,第二次在初始化阶段),因此虚拟机是允许类的字段变量不赋值通过编译器的编译的,但是请不要妄想局部变量也会出现这种情况,局部变量没有进行赋初始化值不能通过编译器。
静态语句块只能访问在这个静态块之前定义的变量。对于定义在静态块后面的变量,静态块可以进行复制操作,但是不能进行访问。
(1)加载
加载就是怎么样在拿到字节流的过程。
在加载阶段,Java虚拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入
(2)验证
验证就是验证拿到的这串字节流是否符合Java语言和Java虚拟机的规范。其中包括:
1)文件格式验证
验证魔数、和版本
2)元数据验证
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相
悖的元数据信息,例如:
这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
3)字节码验证
这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
在JDK 6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到Javac编译器里进行。具体做法是给方法体Code属性的属性表中新增加了一项名为“StackMapTable”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。理论上StackMapTable属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也生成相应的StackMapTable属性来骗过虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。
4)符号引用验证
(3)准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初
始值的阶段。
注意,准备阶段赋的初始值是零值,下图为基本数据的零值:
(4)解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
解析分为对类或接口的解析、对字段的解析、对方法的解析、对接口方法的解析;这些解析大同小异,都是自下向上递归的查询是否有与目标相匹配的名字。
-
比如对一个类或者接口解析:
1)首先通过该类的引用符号拿到全限定名,把这个全限定名传递给类加载器(是外面那一层的类加载器哦),在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。
2)如果这是一个数组,那将会按照第一点的规则加载数组元素类型,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
3)如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,
但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。 -
又比如对一个字段的解析:
1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2)否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3)否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
(5)初始化
在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。也可以理解为初始化阶段就是执行类构造器()方法的过程。()方法是Javac的自动生成物。
()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
虚拟机会保证在所有得子类的()方法执行之前,其父类的()方法已经被执行,因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。
这就意味着,父类的静态块的优先级是优于子类的静态变量赋值操作的。以下代码的执行结果为2。
另外:静态代码块>构造代码块>构造函数>普通代码块
类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
双亲委派模型
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。
五、内存模型、线程同步
1. 内存模型
内存模型解决的是线程的原子性、可见性、指令的排序性三大问题。
每个线程都有自己的工作内存,工作内存集合起来就是Java堆内存,这里面包含着实例字段、静态字段和数组等相关内容,可以视为共享资源,因此对这些资源的操作是需要视为互斥同步问题的。
但是局部变量是不属于该问题范畴的,为什么?这些是线程的私有变量,不存在互斥访问问题。
2. 虚拟机中的原子操作
虚拟机已经保证了以下的8中操作都是具有原子性的,也就是不可再分的。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量
才可以被其他线程锁定。 - read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以
便随后的load动作使用。 - load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的
变量副本中。 - use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚
拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。 - assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,
每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 - store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随
后的write操作使用。 - write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
3. volatile型变量
volatile变量具有两个特征:
- 保证此变量对所有线程的可见性
这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。
你可以理解为:通过volatile关键字,进程之间可以直接共享变量了,而不再通过内存模型中的写入主存等一系列操作来共享变量。
但这并不意味这volatile是线程安全的,为什么?
一致性不等于线程安全,虽然volatile保证更新是对所有线程都是可见的,但是没有保证线程对数据的操作是一气呵成的(也就是并非原子操作)。
想象一个这样的场景,每个线程对一个临界变量进行一个自加操作,按照逻辑,当所有线程都退出的时候,是一个正确得到退出值(线程数×每个线程自加的数量);但是事实上,在取出栈顶操作数的时候,存在一种可能是:线程A取出了正确的操作数写入到主存,但是同时,其他线程也对栈顶进行了自加,于是导致写入主存的数其实是偏小的,所以整体就是偏小的。
但是也有一种情况非常适合使用volatile:这种只需要检测临界资源时否更新的情况
- 禁止指令重排序
volatile是如何进行禁止指令重排序的?
以下是对某段使用volatile关键字的代码的反编译:
红色部分是编译后volatile的那一部分,这个操作在的作用是将该处理器的缓存写入到主存中,这个写入动作也会使其他处理器或者别的内核无效化其缓存,这种操作相当于对缓存中的变量做了一次前面介绍Java内存模式中所说的“store和write”操作。所以通过这样一个空操作,可让前volatile变量的修改对其他处理器立即可见。
拿在硬件上如何解释呢?在计组中学过,CPU对指令的重排序后,运算结果必须是具有唯一性的,也就是说每个处理器重拍后结果是不变的,因此在volatile的那行指令处,无论前面的代码如何重拍,结果都是具有唯一性的**(你也可以想象成volatile处是一个写屏障,只有更新完这个volatile修饰的变量的值到主存,指令流才会继续往下流,而更新这个值到主存必然会被其他线程也观察到)**
4. 内存模型围绕的三个问题
(1)解决原子性
- 6个操作(括read、load、assign、use、store和write)
- synchronized关键字(在字节码指令中表现为monitorenter和monitorexit)
(2)解决可见性
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
- volatile
- synchronized
- final
(3)解决有序性
- volatile
- synchronized(只允许指令串行)
5. 线程的实现
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),
使用用户线程加轻量级进程混合实现(N:M实现)。
(1)内核线程实现
内核级线程:每个用户的轻量级进程其实就是对应操作系统视角的内核线程。
(2)用户线程实现
用户线程:每个用户线程都是建立在用户空间的,不需要进行管态与目态的转换,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。因此操作可以是非常快速且低耗的。
(3)混合实现
混合实现:每个用户线程映射到其轻量级线程,轻量级线程和内核线程再一一对应。
在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。
hotspot采用的是采用1:1的线程模型。(毫不客气地说,使用的是伪多线程)
(4)协程
由于hotspot才用的并非真正的多线程,在操作系统的视角仍然是通过内核级线程进行线程调度的,而内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要
来自于响应中断、保护和恢复执行现场的成本,这一部分的成本如果在大量线程并发的情况下是非常大的。协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。
协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
协程的另一个好处是不需要使用锁机制,为什么?因为协程使用的是线程的资源,线程里的资源本身不是共享的,这就意味着每个协程控制共享资源不加锁,只需要判断状态就好了。
协程的局限性就是需要在应用层面实现的东西非常多(例如调用栈、调度器这些)。
6. 线程安全
《Java并发编程实战(Java Concurrency In Practice)》的作者Brian Goetz为“线程安全”做
出了一个比较恰当的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”
存在五种状态的线程安全
(1)不可变
对于基本类型,使用final关键字。
对于引用类型,类比java.lang.String类的对象实例,它是一个典型的不可变对象,用户调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
(2)绝对线程安全
(3)相对线程安全
在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的
synchronizedCollection()方法包装的集合等。
(4)线程兼容
Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
(5)线程对立
一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对
象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同
步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。
7. 线程安全的实现方法
(1)互斥同步
synchronized
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block
Structured)的同步语法。这种块结构反映在字节码上就是一对monitorenter和monitorexit。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
为什么synchronized 是一种重量级操作?
因为Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或者唤醒一条线程,就需要操作系统来帮忙完成,这就不可避免的需要进行核心态与用户态之间的转化。如果一段特别简单的代码使用了synchronized关键字,显然代价是十分不值得的。
重入锁
自JDK 5起(实现了JSR 166),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。
两者比较
Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不
会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话则可以由Java虚拟机来确保即使出现异常,锁也能被自动释放。
尽管在JDK 5时代ReentrantLock曾经在性能上领先过synchronized,但这已经是十多年之前的胜利了。从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用J.U.C中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
(2)非阻塞同步
互斥同步是一种悲观策略,它总是假设线程再运行过程中,若不进行加锁或者其他机制,就有可能出现临界资源访问冲突问题(但实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
存在另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。
乐观并发策略需要“硬件指令集的发展”?因为我们必须要求操作和冲突检测这两个步骤具备原子性。靠什么来保证原子性?如果这里再使用互斥同步来保证就完全失去意义了,所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:
测试并设置(Test-and-Set);
获取并增加(Fetch-and-Increment);
交换(Swap);
比较并交换(Compare-and-Swap,下文称CAS);
加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。
(3)无同步方案
要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。
-
可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。
可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,
用到的状态量都由参数中传入,不调用非可重入的方法等。我们可以通过一个比较简单的原则来判断代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。 -
线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会
将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。
8. 锁优化
高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock
Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased
Locking)等。
(1)自旋锁与自适应自旋
在并发过程中,为了不让线程很快地阻塞,引入了自旋技术,就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
在自旋锁的基础上,自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。
(2)锁消除
锁消除的简单理解就是:通过逃逸分析技术直接把那些不会发生临界资源访问冲突的锁进行消除,这会大大减少锁机制带来的成本开销。
(3)锁粗化
假设有一个线程对同一个临界资源进行多次操作,虚拟机将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
(4)轻量级锁
虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
(5)偏向锁
偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。