1、JVM管理的内存被分为
方法区,
虚拟机栈,
本地方法栈,
堆,
程序计数器;
程序计数器(线程私有)
1.1、程序计数器是当前线程执行的字节码的行号指示器(通过改变这个计数器的值来实现取指令,分支,循环,跳转,异常恢复,线程恢复);
1.2、JVM多线程通过线程轮流切换实现,每条线程都需要一个独立的程序计数器,独立存储互不影响,为“线程私有”的内存;
虚拟机栈(线程私有)
1.3、描述Java方法执行的内存模型(每个方法执行的同时会创建一个栈帧来存储局部变量表,操作数栈,动态链接,方法出口等信息);方法从执行到完成的过程就是栈帧在虚拟机总入栈到出栈的过程;
1.4、局部变量表存放编译期可知的基本数据类型,对象引用,returnAddress类型;long和double占用2个局部变量空间(字),其余一个;局部变量表所需内存空间在编译期完成分配,进入方法是需要分配多大的局部变量是完全确定的,运行期间不会改变;
1.5、两个异常,栈深度大于虚拟机允许的深度,抛出StackOverflowError异常,内存不够抛出OutOfMemoryError异常;
本地方法栈
1.6、与虚拟机栈类似,虚拟机栈为java方法服务,本地方法栈为Naive方法服务
Java堆(各线程共享)
1.7、堆是Java虚拟机管理内存最大的一块,被所有线程共享,唯一目的是存放对象实例,几乎所有的对象实例
及数组都在这里分配内存;
1.8、Java堆是垃圾收集器管理的主要区域,因此称为GC堆;根据收集器的分代收集算法,分为新生代和旧生代;无论怎么划分,存放的都是对象实例(进一步划分是为了更好的回收,分配内存);
1.9、Java堆可以物理上不连续,只要逻辑上连续即可;没有足够内存时抛出OutOfMemoryError异常;
方法区(各线程共享)
1.10、存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码(被描述为堆的一个逻辑部分,别名非堆)
1.11、HotSpot习惯把方法区称为永久代,因为GC分代收集被扩展到方法区,省的专门写管理内存的方法;
1.12、和Java堆一样不需要连续内存,可以选择不实现垃圾收集;这区域的内存回收目标主要是针对常量池的回收和对类型的卸载;
1.13、Class文件除了有类的相关信息,还有常量池,用于存放编译期生成的各种字面量和符号引用;这部分内容在类加载后进入方法区的
运行时常量池中存放;常量不一定只有编译期产生,运行期也可能将新的常量放入池中;(
String的intern()方法)
直接内存
1.14、不是虚拟机运行时数据区的一部分;
2、对象的创建
2.1、当遇到一条new指令时,首先检查对应的类是否被加载,解析,初始化过。如果没有,必须先执行类加载过程;
2.2、类加载检查通过后,将为新生对象分配内存,对象所需内存在类加载完成后变可完全确定。为对象分配空间相当于把一块确定大小的内存从Java堆中划分出来;(两种方式,
指针碰撞对应于内存规整的情况,
空闲列表对应于内存不规整的情况,空闲内存和已使用内存交错。Java堆是否规整由GC是否带有压缩整理功能决定)。要注意操作的原子性问题(可使用本地线程分配缓存空间
TLAB避免)
2.3、内存分配完成后,虚拟机将分配到的内存空间都初始化为零值;
2.4、对对象进行必要的设置,如类的元数据信息,对象的哈希码,GC分代年龄等;存放在
对象头(Object Header)中;
2.5、一般来说执行new指令后会接着执行<init>方法进行初始化;
对象的内存布局
2.6、HotSpot虚拟机中,对象在内存的布局分为3块区域,
对象头(Header),
实例数据(Instance Data),
对齐填充(Padding);
2.7、对象头包括两部分,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年代,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等(这部分长度为32bit和64bit,官方称为Mark Word);另一部分为类型指针,指向它的类元数据;如果对象是一个数组,对象头中还必须有一块用于记录数组长度的数据(从普通Java对象的元数据可以确定对象的大小,但从数组的元数据中无法确定数组的大小)
对象的访问定位
2.8、Java程序通过栈上的reference数据来操作堆上的具体对象;对象访问方式由虚拟机实现而定,主流的访问方式有两种:
-
- 句柄访问:在Java堆中划分出一块作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息;
- 直接指针访问:reference中存储的直接是对象地址(HotSpot采用这种方式)
3、
垃圾收集器
3.1、一个接口的多个实现类需要的内存可能不一样,一个方法的多个分支需要的内存也可能不一样,只有在程序运行期才能知道会创建那些对象,这部分内存的分配和回收都是动态的
判断对象已死?
3.1、引用计数算法:当有一个地方引用它时,计数器值加1,引用失效是计数器值减1,计数器为0时不能被引用;但是主流的java虚拟机里没有选用引用计数算法管理内存,因为它很难解决对象之间
相互循环引用的问题;
3.2、可达性分析算法:主流的商用程序语言都是通过可达性分析判定对象是否存活;通过一系列的称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,证明此对象是不可用的;在Java语言中,可作为GC Roots的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI(即一般说的Native对象)引用的对象;
Java引用
3.3、JDK1.2以后Java对引用的概念进行了扩充,将引用分为
强引用,
软引用,
弱引用,
虚引用4种:
-
- 强引用:在程序代码中普遍存在的,永远不会被回收掉
- 软引用:描述一些还有用但并非必需的对象,在系统将要发生内存溢出的时候进行第二次回收;
- 弱引用:描述非必需对象的,但强度比软引用更弱,只能生存到下一次垃圾收集发生之前
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。唯一目的是能在这个对象呗收集器回收时受到一个系统通知。
3.4、要宣告一个对象的死亡要经历两次标记过程:如果对象在进行可达性分析到时候发现没有与GC Roots连接的引用链,将会第一次标记,并进行一次筛选,筛选的条件是有没有必要执行
finalize()方法。如果没有覆盖,或已经被虚拟机调用过,则回收。否则对象被放置到一个
F-Queue队列中,稍后执行。执行finalize()方法的时候有可能“拯救”自己。
任何一个对象的finalize只会被系统自动调用一次!
回收方法区
3.5、永久代的回收主要回收两部分内容:废弃常量和无用的类;废弃常量与对象类似,没有其他地方引用这个字面量;判定一个类是否是“无用的类”要苛刻的多:
-
- 该类所有的实例都已被回收
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾回收算法(内存回收的方法论)
3.6、
标记-清除算法:算法分为“标记”和“清除”两个阶段,首先标记出需要回收的对象,在标记完成后统一回收被标记的对象。主要不足有两个,一是效率问题,标记和清除两个过程的效率都不高,另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,会导致以后在程序需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.7、
复制算法(解决效率问题):将可用内存分为两部分,每次只使用其中一部分。当这一块的内存用完了就将还存活的对象复制到另一块上面,再把已经使用过的内存空间一次清理掉,这样使得每次都对整个半区进行内存回收。内存分配也就不用考虑内存碎片等复杂情况。代价是将内存缩减为原来的一半。(改进:分成
Eden 80% + survivor 10% + survivor 10%,若10%的survivor不够就进行分配担保)(缺点:对象存活率较高时就要进行较多的复制操作,效率降低) -->当前商用虚拟机都采用这种收集算法来回收
新生代
3.8、
标记-整理算法:比较完以后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存;
3.9、
分代收集算法:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分成新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法;新生代中每次垃圾收集都有大批对象死去,少量存活,那就选用复制算法;老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法进行回收;
垃圾收集器(内存回收的具体实现)
-
- Serial收集器:并不是只它是单线程的,而是它进行垃圾收集时,必须暂停其他所有的工作线程;->这是不可避免的,HotSpot一直在改进缩短用户线程的停顿时间;在运行在Client模式下的虚拟机来说是不错的选择,简单而高效;
- ParNew收集器:Serial收集器的多线程版本。stop The World,对象分配规则,回收策略与Serial一样;是许多运行在Server模式下的虚拟机首选的新生代收集器,因为除了Serial,只有它能与CMS收集器配合工作;
- Parallel Scavenge收集器:新生代收集器,多线程,CMS等收集器关注的是尽可能缩短垃圾收集时用户线程停顿的时间,而Parallel Scavenge收集器的目的是达到一个可控制的吞吐量(=用户代码时间/(用户代码时间+垃圾收集时间),即CPU用于运行用户代码的时间与CPU总消耗时间的比值)。
- CMS(Concurrent Mark Sweep)收集器:CMS收集器是一种以获取最短回收停顿时间为目标的收集器。分为四个步骤
- 初始标记(stop the world):标记GC Roots能直接关联到的对象,速度很快
- 并发标记:进行GC Roots Tracing的过程
- 重新标记(stop the world):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
- 并发清除
- 缺点:
- 对CPU资源敏感(并发设计),虽然用户线程不停顿,但导致应用程序变慢
- 因为收集期间用户程序还在运行,所以不能再老年代填满的时候再收集,需要预留一部分空间提供并发收集时的程序运作使用。
- 内存碎片(以上两个缺点会导致程序停顿来被迫收集)
- G1(Garbage-First)收集器:面向服务端应用的垃圾收集器,具有以下特点:
- 并行与并发:利用多CPU、多核环境的硬件优势,使用多个CPU来缩短stop-the-world的时间(可以设置!);
- 分代收集
- 空间整合
- 可预测的停顿
- 使用G1收集器时,将整个Java堆划分为多个大小相等的独立区域(不再只是新生代老年代);虽然还保留新生代老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合
- 不再是在Java堆全区域进行垃圾收集,而是跟踪各个Region里面垃圾堆积的价值大小,维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
- 在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描;
- G1的主要步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
4、
内存分配与回收策略
4.1、对象优先在新生代 Eden区分配,当没有足够的空间时,虚拟机发起一次Minor GC
4.2、大对象直接进入老年代。大对象是指需要大量连续内存空间的Java对象。最典型的的大对象就是那种很长的字符串以及数组。尽量避免使用“朝生夕灭”的大对象
4.3、长期存活的对象将进入老年代。虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移入到Survivor空间中,并将年龄设为1,在Survivor中每经过一次Minor GC,年龄增加1岁。当年龄增加到一定程度(默认15)就将会被晋升到老年代中。
4.4、动态对象年龄判定。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代;
4.5、空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果成立,则Minor GC确保安全,否则检查是否允许担保失败。允许就会继续检查老年代最大可用的连续空间是否大于历次晋级到老年代对象的平均大小,允许就尝试Minor GC,否则进行一次Full GC。(ps. 新生代使用复制算法,需要老年代分派担保,把Survivor无法容纳的对象直接进入老年代)
5、
Class类文件的结构
5.1、class文件采用一种类似于C语言结构体的伪结构存储数据,只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,以u1, u2, u4, u8代表1个字节,2个字节,4个字节和8个字节的无符号数。无符号数可以用来描述数字,索引引用,数量值或者按照UTF-8编码构成字符串值。表是由多个无符号数或者其他表作为数据项构成的复合数据类型。所有表以"_info"结尾。整个Class文件本质上就是一张表。表的数据项,包括顺序,数量,数据存储的字节序都是固定的!
5.2、class文件的头4个字节称为魔数(0xCAFEBABE);紧接着是4个字节存储的class文件的版本号(次版本号+主版本号)
5.3、紧接着主次版本号之后是常量池的入口(class文件之中的资源仓库),入口需要放置一项u2类型的数据代表常量池容量计数值。常量池容量计数从1开始(0x0016=22,代表有21项容量;某些指向常量池的索引值的数据置为0表示不引用任何一个常量池项目)。只有常量池从1开始,其他均从0开始。常量池存放字面量和符号引用,字面量如文本字符串,声明为final的常量值等,符号引用包括了:
-
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
TODO...
6、虚拟机类加载机制
6.1、类从被加载到虚拟机内存到卸载出内存,整个生命周期包括:加载,验证,准备,解析,初始化,使用和卸载七个阶段。其中验证,准备,解析三个部分统称为连接。解析阶段有可能在初始化之后开始,为了支持java语言的运行时绑定。
6.2、什么时候实现类类加载不固定,但是规定了有且只有五种情况必须立即对类进行初始化(加载,验证,准备自然需要先开始):
-
- 遇到new,getstatic,putstatic,invokestatic这四条字节码指令时,如果类没有进行初始化,必须触发其初始化。最常见的场景是new实例化对象,读取或设置一个类的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法时;
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,必须触发其初始化;
- 当初始化一个类的时候,如果其父类没有进行过初始化,先触发其父类的初始化;
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类;
- 如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic, REF_putStatic, REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,先触发其初始化;
- 注意:
- 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化,而不触发子类的初始化;
- 通过数组定义来引用类,不会触发此类的初始化
- 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化;
- 接口与类稍有不同,主要在以上第三点:类初始化时要求父类都已经初始化过,而接口在初始化时,不要求父接口全部完成初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化;
类加载过程
6.3、加载:加载是类加载的一个阶段,在加载阶段虚拟机完成一下三件事:
-
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 从ZIP中获取:日后成为war, jar, ear格式的基础
- 从网络获取:applet
- 运行时计算生成:动态代理技术
- 其他文件生成:JSP应用,由JSP文件生成对应的class类
- 从数据库读取:有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发;
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.class对象,作为方法区这个类的各种数据的访问入口
- 通过一个类的全限定名来获取定义此类的二进制字节流
6.3.1、数组类本身不通过类加载器创建,由java虚拟机直接创建;一个数组类的创建过程遵循以下规则:
-
-
- 如果数组的组件类型是引用类型,就递归使用加载过程去加载这个组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识
- 如果数组的组件类型不是引用类型,java虚拟机将会把数组标记为与引导类加载器相关联
- 数组类的可见性与组件类的可见性一致,如果组件类型不是引用类型,那数组的可见性将默认为public;
-
6.4、验证:确保class文件的字节流中包含的信息符合当前虚拟机的要求,不会危害虚拟机自身的安全;
-
- 文件格式验证:
- 是否以魔数0xCAFEBABE开头
- 主次版本号是否在当前虚拟机处理范围之内
- 常量池中的常量是否有不被支持的常量类型
- 指向常量的各种索引值中是否有指向不确定的常量或不符合类型的常量
- ……
- 元数据验证(语义分析)
- 这个类是否有父类
- 这个类的父类是不是继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段,方法是否与父类产生矛盾(如覆盖了父类的final方法)
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的
- 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候
- 文件格式验证:
6.5、准备:正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。注意这里内存分配的仅包括类变量(被static修饰的变量),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。这里说的初始值通常情况是数据类型的零值;
6.6、解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
-
- 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能无歧义地定位到目标即可(与虚拟机内存布局无关)
- 直接引用:直接引用可以使直接指向目标的指针、相对偏移量或是一个能简介定位到目标的句柄(与虚拟机内存布局有关)
6.6、初始化:初始化是类加载过程的最后一步,前面的类加载过程除了在加载阶段用户可以通过自定义类加载器参与之外,均由虚拟机主导和控制;到了初始化阶段才开始执行类中定义的java代码(或者说字节码)
-
- 初始化是执行类构造器<clinit>()方法的过程;
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在之后的变量,在前面的静态语句块可以赋值,但不能访问;
- <clinit>()方法与类的构造函数不同,它不需要显式调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕;
- 由于父类的<clinit>()方法先执行,意味着父类中定义的静态语句要优先于子类的变量赋值操作
- 如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法;
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会产生<clinit>()方法,但接口的<clinit>()方法不需要执行父接口的<clinit>()方法,只有当父接口中定义的变量使用时,父接口才会初始化;接口的实现类在初始化的时候也一样不会执行接口的<clinit>()方法;
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中被加锁,同步,如果多个线程同时初始化一个类,那么只有一个线程去执行<clinit>()方法,其他线程需要阻塞等待;
虚拟机字节码执行引擎
TODO
7、前期(编译期)优化
7.1、java语言的编译期是一段不确定的操作过程,可以是:
-
- 前端编译器:将*.java文件转变成*.class文件的过程;代表是sun的javac
- 虚拟机的后端运行期编译器(JIT):将字节码转变成机器码的过程;代表是HotSpot VM的C1, C2编译器;
- 静态提前编译器(AOT):直接把*.java文件编译成本地机器码的过程;代表是GNU Compiler for the Java(GCJ)
7.2、javac对代码的运行效率几乎没有任何优化策略,虚拟机设计团队把对性能的优化集中到了后端的即时编译器中
Javac编译器
7.3、javac编译器是由java语言编写的程序;从javac的代码来看,编译过程大致分为三个过程,分别是
-
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成过程
7.4、解析与填充符号表:解析由parseFiles()方法完成:
-
- 词法、语法分析:词法分析将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,标记则是编译过程的最小元素;语法分析是根据Token序列构造出抽象语法树(AST)的过程
- 填充符号表:完成了语法分析和词法分析之后,下一步就是填充符号表
7.5、注解处理器:JDK 1.5以后java语言提供了对注解的支持,这些注解与普通的java代码一样,是在运行期间发挥作用的;插入式注解处理器可以看做是一组编译器插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素;
7.6、语义分析与字节码生成:语法分析之后编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查;(语法树只保证结构正确!不保证语义合法!)
Java语法糖
7.6、泛型与类型擦除:泛型是JDK 1.5的新增特性,本质是参数化类型的应用,即所操作的数据类型被指定为一个参数,这种参数类型可以用在类,接口和方法的创建中,分别称为泛型类,泛型接口和泛型方法;
7.7、C#的泛型是切实存在的,List<int>和List<String>是两种不同的类型,在系统运行期生成,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型;Java语言的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中替换为原生类型,并且在相应的地方插入了强制转型代码,所以泛型技术实际上是Java语言的一颗语法糖。Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型;擦除法对方法的重载产生影响,方法重载的要求是具备不同的特征签名,返回值不包含在方法的特征签名中。如果类型擦除以后,相同返回值的方法将会一模一样,导致不能编译,只能修改他们的返回类型来完成重载;
7.8、自动装箱,拆箱,遍历循环
7.8.1、
== 在操作数是基本数据类型时,是比较值是否相等,在操作数是引用类型时,比较的是是否指向堆中同一个对象。
Integer的equals比较的被赋予的值(一般都是比较这个,String也是)。
7.8.2、
自动装箱其实就是编译器编译的时候,自动帮你把int变成Integer对象,调用的是Integer的静态方法valueOf();注意
当你的int的值在-128-IntegerCache.high(127) 时,返回的不是一个新new出来的Integer对象,而是一个已经缓存在堆中的Integer对象,这会导致两个对象==为true或false;即-128~127为true;
条件编译
7.9、使用条件为常量的if语句,在编译阶段就会“运行”,编译完只剩下会被“运行”的那个分支;这也是java语言的一颗语法糖,根据布尔常量值的真假,编译器把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成;
7.10、除了以上介绍的泛型,自动装箱,自动拆箱,遍历循环,变长参数和条件编译,Java语言其他的语法糖:内部类,枚举类,断言语句,对枚举和字符串的switch支持,try语句中定义和关闭资源等;
8、晚期(运行期)优化
TODO
9、Java内存模型与线程
9.1、由于计算机的存储设备与处理器运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲;但引入了一个新的问题:缓存一致性;为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。
9.2、除了增加告诉缓存以外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的;Java虚拟机的即时编译器中也有类似的指令重排序优化;
9.3、Java虚拟机规范试图定义一种Java内存模型(JMM),使Java程序在各平台下达到一致的内存访问效果;Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节;->此处的变量包括实例字段,静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享;
9.4、Java内存模型规定所有的变量都存储在主内存,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也不能直接访问对方工作内存中的变量,线程间变量值的传递均需通过主内存来完成;(主内存主要对应于Java堆中的对象实例数据部分,工作内存对应于虚拟机栈中的部分区域;从更低层次来讲,主内存直接对应于物理硬件的内存,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存)
9.5、变量从主内存到工作内存和工作内存同步到主内存共有8种操作(每一种都是原子的,不可再分的):
-
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便以后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时会执行这个操作;
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便以后的write操作使用
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
9.6、Java内存模型还制定了以下规则:
-
- 不允许read和load,store和write操作之一单独出现
- 不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步到主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock可以被同一条线程重复执行多次;多次执行lock后,需要执行相同次数的unlock解锁;
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign操作初始化变量的值
- 如果一个变量事先没有被lock,那么不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量
- 对一个变量执行unlock操作之前,必须先把变量同步到主内存中(执行store,write)
9.7、volatile具备两种特性,一是对所有线程的可见性:当一条线程修改了这个变量的值,新值对于其他线程来说是可立即得知的,而普通变量做不到这一点(普通变量的值在线程间传递需要通过主内存来完成);volatile变量在各个线程的工作内存中是一致的,但它并不是并发安全的!因为java里面的运算并非是原子操作(即使是一条字节码指令也不是原子的!)
9.8、由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized或java.util.concurrent)来保证原子性;
-
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
9.9、使用volatile变量的第二个语义是禁止
指令重排序优化;有volatile修饰的变量赋值后多执行一个lock...操作,这个操作相当于一个内存屏障,指重排序时不能把后面的指令重排序到内存屏障之前的位置。只有一个CPU访问内存时不需要内存屏障,如果有多个CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障保证一致性;参考P371内存屏障的原理。
9.10、假设T为线程,V和W为两个volatile变量,在进行read,load,use,assign,store,write操作是需要满足一下规则:
-
- T对V执行只有前一个动作是load时,才可以执行use,只有执行的后一个动作是use的时候,线程T才对V执行load操作;
- 只有当线程T对V执行的前一个动作是assign时,线程T才对V执行store操作,并且只有当线程对V执行的后一个动作是store的时候,T才对V执行assign
9.11、虚拟机实现选择可以不保证64位数据类型的load、store、read、write这四个操作的原子性,这就是long和double所谓的非原子性协定;
9.12、原子性,可见性与有序性:
9.12.1、原子性:java内存模型直接保证的原子性变量操作包括read、load、assign、use、store、write;更大范围的原子性保证可以使用lock和unlock(虚拟机未直接开放给用户,但提供了更高层次的monitorenter和monitorexit来隐式使用,反映到java代码块中就是同步快,synchronized关键字,因此synchronized块之间的操作也具备原子性);
9.12.2、可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改;java内存模型是通过在变量修改之后将新值同步到主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的;无论普通变量和volatile变量都是如此,普通变量和volatile变量之间的区别是volatile的特殊规则保证了新值能够立即同步到主内存,以及每次使用前立即从主内存刷新,因此,volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点;除了volatile以外,synchronized和final也能实现可见性。同步快的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store、write操作)”这条规则获得的;而final关键字的可见性是指被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去,那在其他线程中就能看见final字段的值;
9.12.3、有序性:Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的;前半句是指线程内表现为串行的语义,后半句是指指令重排序现象和工作内存和主内存同步延迟现象;java提供了volatile和synchronized来保证线程之间操作的有序性;volatile本身禁止了指令重排序的语义,synchronized则是由“一个变量在同一个时刻值允许一个线程对其进行lock操作”,这条规则决定了持有同一个锁的两个同步块只能串行的进入;
先行发生原则
9.13、先行发生是java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到;影响包括修改了内存中共享变量的值,发送了消息,调用了方法等;Java内存模型有一些天然的先行发生关系:
-
- 程序次序规则(在一个线程内)
- 管程锁定规则(同一个锁的lock与unlock)
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个动作
- 线程终止规则:一个对象的初始化完成先行发生于它finalize()方法的开始
- 传递性:A先行发生于B,B先行发生于C,则A先行发生于C
时间先后顺序与先行发生原则之间基本没有太大的关系,一切以先行发生原则为准
java线程
9.14、线程的实现:线程是比进程更轻量级的调度执行单位,线程的引入可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址,文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。每个执行了start()还未结束的java.lang.Thread类的实例就代表一个线程;
9.15、实现线程主要有3种方式:
-
- 使用内核线程(KLT)实现:就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核本身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核;程序一般不直接使用内核线程,而是使用内核线程的高级接口——轻量级进程(LWP),也就是我们通常意义上的线程;缺点是系统调用代价相对较高,需要在用户态和内核态中来回切换,一个系统支持轻量级进程的数量是有限的。
- 使用用户线程实现:一个线程只要不是内核线程,就可以认为是用户线程,因此轻量级进程也属于用户线程(但轻量级进程的实现始终是建立在内核之上)。狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现,用户线程的建立,同步,销毁,调度完全在用户态完成,不需要内核的帮助。优势是操作快速低消耗,可以支持规模更大的线程数量,缺点是没有系统内核的支援,所有的线程操作需要用户程序自己处理(一般比较复杂,Java已放弃使用);
- 用户线程加轻量级进程混合实现
9.16、Java线程调度:线程调度是指系统为线程分配处理器使用权的过程,主要方式有两种,分别是协同式线程调度和抢占式线程调度;
-
- 协同式系统调度,线程的执行时间由线程本身来控制,线程把自己的工作执行完以后通知系统切换到另一个线程上。最大的好处是实现简单,没有什么线程同步的问题(因为切换操作对线程自己是可知的);坏处就是容易阻塞,导致系统崩溃;
- 抢占式调度,每个线程由系统分配执行时间,线程的切换不由线程本身决定(可以自己让出,但不能随意获得);这种方式下,线程的执行时间是系统可控的,不会出现进程阻塞的问题。Java采用的调度方式就是抢占式调度。虽然调度由系统完成,但是可以设置线程优先级,当两个线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行;(但是,java线程设置的优先级不一定和系统原生线程的优先级对应!并不是很靠谱,不要过度依赖)
9.17、状态转换。java语言有5种线程状态:
-
- 新建:创建后尚未启动的线程
- 运行(Runable):包括了操作系统线程状态中的Running和Ready,有可能正在执行,有可能等待CPU分配执行时间
- 无限期等待(Waiting):这种状态的线程不会被分配CPU执行时间,要等待被其他线程显式地唤醒;以下方法会让线程陷入无限期等待的状态:
- 没有设置Timeout参数的Object.wait()方法
- 没有设置Timeout参数的Thread.join()方法
- LockSupport.park()方法
- 限期等待(TImed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须被其他线程显式地唤醒,在一定时间后由系统自动唤醒,以下方法会让线程陷入限期等待状态:
- Thread.sleep()方法
- 设置了Timeout参数的Object.wait()方法
- 设置了Timeout参数的Thread.join()方法
- LockSupport.parkNanos()方法
- LockSupport.parkUntil()方法
- 阻塞(Blocked):线程被阻塞,与等待状态的区别是,阻塞状态在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。而等待状态则是在等待一段时间或唤醒动作的发生。程序等待进入同步区域的时候,线程将进入这种状态;
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行;
10、线程安全与锁优化
10.1、
线程安全:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象时线程安全的;(一般会把调用这个对象的行为弱化为单次调用)
10.2、按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下5类:
-
- 不可变(Immutable):如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰就可以保证不可变。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行;String对象不可变!而是返回一个新构造的字符串对象;(还有枚举类型,java.lang.Number的部分子类,Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型)
- 绝对线程安全:Java中标注自己线程安全的类,大多都不是绝对的线程安全
- 相对线程安全:这是通常意义上的线程安全,需要保证对这个对象单独的操作是线程安全的(但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性)。Java语言中Vector,HashTable,Collections的synchronizedCollection()方法包装的集合等都属于这种类型;
- 线程兼容:对象本身不是线程安全的,但可以通过在调用端正确使用同步手段来保证在并发环境中安全使用;Java API中大部分类都是线程兼容的,如与前面的Vector和HashTable对应的集合类ArrayList和HashMap等;
- 线程对立:指无论调用端是否采取了同步措施,都无法再多线程环境中并发使用的代码;一个线程对立的例子是Thread类的suspend()和resume()方法,如果两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的;如果suspend()中断的线程就是即将要执行resume()的线程,那就肯定要产生死锁了;
10.3、线程安全的实现方法
涉及到代码的编写和虚拟机提供的同步与锁
-
- 互斥同步:同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用;互斥是实现同步的一种手段,临界区,互斥量和信号量都是主要的互斥实现方式;(互斥是因,同步是果;互斥是方法,同步是目的);
- Java里最常见的互斥同步手段是synchronized关键字,需要指定一个reference类型的参数来指明要锁定和解锁的对象。如果synchronized明确指定了对象参数,那就是这个对象的reference,如果没有明确指定,就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或class对象来作为锁对象;如果获取对象失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止;synchronized是一个重量级的操作,不必要的话不使用,防止频繁的切换内核态和用户态(操作系统内核提供切换功能)
- 除了使用synchronized意外,还能使用java.util.concurrent的重入锁(ReentrantLock)来实现同步;ReentrantLock提供一些更高级功能,等待可中断,可实现公平锁,锁可以绑定多个条件:
- 等待可中断是指当前持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,对处理执行时间非常长的同步块很有帮助
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁不保证,锁被释放的时候,任何一个等待的线程都有机会获得锁;synchronized是非公平锁,ReentrantLock默认不公平,但可以通过带布尔值的构造函数要求使用公平锁
- 锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()和notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外的添加一个锁(?)。synchronized(JDK 1.5)在多线程环境下吞吐量下降的非常严重,具有很大的优化空间;JDK 1.6后与ReentrantLock性能持平;
- 非阻塞同步:互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步;随着硬件指令集的发展,我们有另一种选择:基于冲突检测的乐观并发策略;就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,那就采取其他补偿措施(比如不断重试直到成功)。 硬件指令集的发展是指,我们需要操作和冲突检测这两个步骤具备原子性;关注P395例子
- 无同步方案:同步只是保证共享数据争用时的正确性的手段。有一些代码天生就是线程安全的
- 可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误;可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重用的方法;
- 线程本地存储:如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码能不能保证在同一个线程中执行,即把共享数据的可见范围限制在同一个线程之内;大部分使用消费队列的架构模式都会将产品的消费过程尽量在一个过程中消费完,比如web交互模型中的“一个请求对应一个服务器线程”的处理方式,来解决线程安全问题;在Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明为易变的;
- 互斥同步:同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用;互斥是实现同步的一种手段,临界区,互斥量和信号量都是主要的互斥实现方式;(互斥是因,同步是果;互斥是方法,同步是目的);
10.4、锁优化
10.4.1、
自旋锁与自适应自旋:互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程需要转入内核态,给系统的并发性能带来很大压力;而且共享数据的锁定状态统称很短,没必要去挂起恢复线程;如果物理机器有一个以上的处理器,可以让后面请求锁的线程稍等一下(执行一个忙循环->自旋),但不放弃处理器的执行时间,看持有锁的线程是否很快就会释放锁
-
-
- 自旋避免了线程的切换开销,但占用处理器的时间;如果锁被占用时间长,会白白消耗处理器资源,浪费了性能;所以,自旋默认的次数是10次。
- 自适应的自旋锁以为这自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态决定;
-
10.4.2、
锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除;锁消除的主要判定依据来源于
逃逸分析的数据支持;如果判断在一段代码中,堆上的所有数据都不会逃逸出去而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步枷锁就无须进行;P399例子
10.4.3、
锁粗化:原则上我们希望将同步快的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使需要同步的操作数量尽可能小。但是如果一系列连续都对同一个对象反复加锁解锁,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗;(比如StringBuilder连续的append()时)虚拟机会把加锁同步的范围扩展(粗化)到整个操作序列的外部,只需要加锁一次;
10.4.5、
轻量级锁:相对于使用系统互斥量来实现的传统锁(即重量级锁)。轻量级锁不是代替重量级锁,它的本意是在
没有多线程竞争的前提下(否则性能更低),减少传统的重量级锁使用系统互斥量产生的性能损耗;参见P400 Mark Word
10.4.6、偏向锁:消除数据在无竞争情况下的同步原语,如果轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS也不用做;偏的意思就是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步;当有另外一个线程尝试获取这个锁时,偏向模式宣告失败;