Java虚拟机(JVM)原理与特性

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)之间的桥梁,这样就可以使用内核

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值