第二部分 自动内存管理机制
第2章 Java内存区域与内存溢出的异常
2.1运行时数据区域
那么运行时数据区包括哪几部分呢?
- 程序计数器(Program Counter Register)
- Java虚拟机栈(VM Stack)
- 本地方法栈(Native Method Stack)
- 方法区(method area)
- 堆(heap)
1、程序计数器
用来指示程序执行哪一条指令,这跟汇编语言的程序计数器的功能在逻辑上是一样的。JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令地址,如果线程执行的是native方法,则程序计数器中的值undefined。每个线程都有自己独立的程序计数器。为什么呢?因为多线程下,一个CPU内核只会执行一条线程中的指令,因此为了使每个线程在线程切换之后能够恢复到切换之前的程序执行的位置,所以每个线程都有自己独立的程序计数器。
2、Java虚拟机栈
Java虚拟机栈中存放的是一个个栈帧,当程序执行一个方法时,就会创建一个栈帧并压入栈中,当方法执行完毕之后,便会将栈帧移除栈。我们所说的“栈”是指Java虚拟机栈,一个栈帧中包括:局部变量表、操作数栈、动态连接、方法返回地址、附加信息
局部变量表
主要是存储方法中的局部变量,包括方法中局部变量的信息和方法的参数。如:各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址),其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
操作数栈
虚拟机把操作数栈作为它的工作区,程序中的所有计算过程都是在借助于操作数栈来完成的,大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用(指向运行时常量池:在方法执行的过程中有可能需要用到类中的常量),持有这个引用是为了支持方法调用过程中的动态连接
方法返回地址
当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。
3、本地方法栈、
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
4、堆(heap)
在C语言中,程序员可以通过malloc函数和free函数在堆上申请和释放空间。那么在Java中是怎么样的呢?Java中的堆是用来存储对象本身的以及数组(当然,数组引用是存放在Java栈中的),几乎所有的对象实例都在这里分配内存。在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。另外,堆是被所有线程共享的,在JVM中只有一个堆。
5、方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、以及编译器编译后的代码等。运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。
2.3hotspot虚拟机对象
句柄访问,对象移动时,只会改变句柄中实例的数据指针。
Sum Hotspot采用的直接指针访问对象, 减少第二次寻址。
2.4OurOfMemoryError异常
Heap堆内存溢出
-Xms JVM初始分配的堆内存,
-Xmx JVM最大允许分配的堆内存,
-XX:apDumpOnOutOfMemoryError
MAT内存分析工具
虚拟机栈和本地方法栈溢出
-Xss设置栈大小, -Xoss设置本地方法栈大小(实际无效)。
线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverFlowError。
虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError。
方法区和运行时常量池溢出
-XX:PermSize JVM初始分配的非堆内存
-XX:MaxPermSize JVM最大允许分配的非堆内存
本机直接内存溢出
-XX:MaxDirectMemorySize指定DirectByteBuffer能分配的空间的限额,如果没有显示指定这个参数启动jvm,默认值是xmx对应的值。
第3章 垃圾收集器与内存分配策略
3.1概述
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出执行者出栈和入栈操作。这几个区域内存分配和回收都具备确定性,方法结束或者线程结束时, 内存自然跟着回收。
Java堆和方法区, 内存分配不固定,内存的分配和回收都是动态的,垃圾收集器所关注的也是这里。
3.2对象已死么
引用计数法
主流Java虚拟机没有采用,主要原因是很难解决对象之间相互循环引用的问题。
可达性分析算法
主流商用程序语言(Java、C#)通过可达性分析来判定对象是否存活。
利用引用链判断对象是否可回收。
GC Roots对象包括以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象;
方法区中的静态属性引用的变量;
方法区中常量引用的对象;
本地方法栈中JNI(Native方法)引用的对象;
再谈引用
强引用 只要存在,垃圾收集器永远不会回收掉被引用的对象;
软引用:有用但非必须的引用,当内存不足时, 此类引用的对象会被回收;
弱引用:非必须的引用,进行垃圾回收时,此类对象会被回收;
虚引用:虚引用不对对象生存时间构成影响,无法通过此类引用获取对象实例,使用目的是对象被回收时收到一个系统通知。
对象回收
一个对象的回收需要经历两次标记过程,如果可达性分析后没有与GC Roots相连接的引用链,会被第一次标记并进行一次筛选,筛选有没有必要执行finalize()方法(当对象没有覆盖finalize方法,或者finalize被执行过, 则视为没有必要执行finalize)。如果必要执行finalize方法,对象被放置在F-Queue队列中,由低优先级的Finalize去执行, 并不保证对象的finalize方法一定执行完(执行时间过长会影响队列其他对象),这是对象最后的自我拯救(成功则第二次标记时移除即将回收的集合),对象的finalize()方法只会执行一次,之后在经过一次标记确定最终是否被回收。
回收方法区
方法区的垃圾回收性价比不高,一般情况下可回收的空间很少,主要回收废弃的常量和无用的类。
废弃常量回收的条件是,该常量不被引用。
无用的类回收的条件是,该类所有的实例都已经被回收,加载该类的ClassLoader已经被回收,该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法,但不一定会被回收。
3.3垃圾收集算法
标记-清除算法(Mark-Sweep)
首先表记需要被清除的对象,标记完成后统一回收被标注的对象。缺点:效率不高,会产生大量不连续的内存碎片。
复制算法(Copying)
可用内存分为大小相等的两块,当其中一块内存用完,将还存活的对象复制到另一块上,清理掉之前使用的一块内存。缺点:可用内存缩小为了原来的一半。
标记-整理算法(Mark-Compact)
标记过程与“标记-清除”算法一样,然后让所有存获得对象都向一段移动,然后清理掉边界以外的内存。
分代收集算法(Generational Collection)
根据对象存活周期的不同将内存划分为几块,堆一般分为新生代和老年代,新生代选用复制算法,老年代用标记-清理或标记-整理算法来进行回收。
3.4HotSpot的算法实现
枚举根节点
安全点
安全区域
3.5垃圾收集器
Serial收集器
ParNew收集器
Paraller scavenge收集器
CMS收集器
G1收集器
3.6内存分配与回收策略
对象优先在Eden区进行分配
对象优先在Eden区进行分配, 当Eden区没有足够的空间的时候,虚拟机发起一次Minor GC(发生在新生代的垃圾回收动作)。如果Minor GC 后, 另一个Servivo区空间不足以存在原Eden区存活对象, 只好通过分配担保机制提前转移到老年代去。
-XX:+PrintGCDetail 告诉虚拟机在垃圾回收的时候打印回收日志
-XX:SurvivorRatio=8 设置新生代Eden区与一个Survivor区空间比例
大对象直接进入老年代
打对象指需要大量连续内存空间的Java对象,例如很长的字符串以及数组。
-XX:PretenureSizeThreshold参数,大于这个设定值的参数直接在老年代分配。以避免在Eden区与两个Servivor区之间发生大量的内存复制(新生代采用复制算法收集内存)。
长期存活的对象将直接进入老年代
Eden区出生经过一次Minor GC后仍存活,并且能被Servivor容纳的话,将被移动到Servivor空间, 年龄默认为1岁,每经过一次Minor GC,年龄就增加一岁,累积到一定的年龄就会被晋升到老年代。
-XX:MaxTenuringThreshold 设置对象晋升到老年代的年龄阈值
动态对象年龄判断
虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,如果在Servivor空间中相同年龄对象空间总和大于Servivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入到老年代中。
空间分配担保
在Minor GC之前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象的总空间,条件成立则Minor GC是安全的。如果不成立,则查看HadlePromotionFailure设置值是否允许担保失败。如果允许,会检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,大于则进行一次Minor GC,有风险;如果小于,或者HandlePromotionFailure不允许担保失败,也要更改为执行一次Full GC。
第4章 虚拟机性能监控与故障处理工具
jps
jstat
JConsole java监视与管理平台
Visualvm 多合一故障处理工具
第三部分 虚拟机执行子系统
第6章 类文件结构
Class类文件结构
第7章 虚拟机类加载机制
JVM类加载机制分为五个部分:加载,验证,准备,解析,初始化
类加载的时机
主动引用
虚拟机规范严格规定了有且只有5中情况(jdk1.7)必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到new,getstatic,putstatic,invokestatic这失调字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
被动引用
1.通过子类来引用父类中定义的静态字段,只会触发父类的初始化,不会触发子类的初始化。
2.通过数组定义引用类,不会触发类的初始化,会由虚拟机自动生成直接继承Object的子类。
3.常亮在编译阶段会存入调用类的常量池中,本质上并没有直接引用定义常量的类,因此不会触发定义常量类的初始化。常量被存储到NotInitialzation类的常量池中,对于常量的引用被转化为NotInitialization类对自身常量池的引用。
接口与类区别:当一个类初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父类接口全部都完成了初始化,只有在真正使用父接口的时候(引用接口中定义的常量)才会初始化。
类加载的过程
加载
加载阶段,虚拟机完成的三件事情:
1.通过类的权限定名来获取定义此类的二进制字节流;
2.将这个字节流所代表的静态存储结构转换为方法去的运行时数据结构;
3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法去这个类的各种数据的访问入口;
验证
目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,不会危害虚拟机。
1.文件格式验证,演示字节流是否符合Class文件格式规范,能被当前版本的虚拟机处理;
2.元数据验证,对字节码描述的信息进行语义分析;
3.字节码验证:通过数据流和控制流分析,确认程序语义合法、符合逻辑,对类的方法体进行分析;
4.符号引用分析:发生在虚拟机将符号引用转化为直接引用时,对类自身以外(常量池中的各种符号引用)的信息进行匹配性效验。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,内存都在方法区中分配。内存分配仅包含类变量(static的),实例变量在对象实例化时随对象一起分配在堆中。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
此阶段才真正开始执行类中定的Java程序代码。
执行类构造器<clinit>:
<clinit>收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生,收集顺序由语句在源文件中出现的顺序所决定,静态语句块中只能访问定义在静态块之前的变量,定义在它之后的变量,可以在前面的静态语句块中赋值但是不能访问。
<clinit>方法与类的构造不同,不需要显示的调用父类构造器,虚拟机会保证父类的<clinit>在子类执行前执行完毕,第一个肯定是Object。
<clinit>由于父类的先执行,也就意味着父类的静态语句块要优先与子类的变量赋值操作。
<clinit>不是必须的,如果没有静态语句块,也没有变量赋值操作,不会生成<clinit>方法。
接口中不能用静态语句块,但会有赋值操作,因此<clinit>不需要先执行父类的<clinit>方法。只有当父类接口中定义的变量使用时,父接口才会初始化。接口的实现类在初始化时也不会执行接口的<clinit>。
<clinit>在多线程中被正确的加锁、同步,多个线程同时初始化一个类时,只有一个线程执行<clinit>,其他的阻塞等待直到活动线程执行完。
类加载器
类加载阶段中“通过一个类的全限定名来获取描述此类的二进制字节流”动作放到虚拟机外部实现,以便让程序自己决定如何去获取所需要的类。
类加载器和类本身一同确立其在虚拟机中的唯一性。比较两个类是否相等,前提是由同一个类加载器加载。
双亲委派模型
双亲委派模型的工作过程:如果一个类加载器收到了类加载器的请求.它首先不会自己去尝试加载这个类.而是把这个请求委派给父加载器去完成。每个层次的类加载器都是如此。因此所有的加载请求最终都会传送到Bootstrap类加载器(启动类加载器)中.只有父类加载反馈自己无法加载这个请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
启动类加载器(Bootstrap ClassLoader):负责将<JAVA_HOME>/lib目录或者指定路径下虚拟机可识别的类库(*.jar)加载到虚拟机内存中。该加载器无法被Java程序直接引用,用户编写的自定义类加载器需要把加载请求委派给引导类加载器。
扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现,负责加载%JAVA_HOME%\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径classpath上所指定的类库,是类加载器ClassLoader中的getSystemClassLoader()方法的返回值,开发者可以直接使用应用程序类加载器,如果程序中没有自定义过类加载器,该加载器就是程序中默认的类加载器。
注意:并不是强制性的。
破坏双亲委派模型
JDK1.2向上兼容,JNDI,程序动态性。
线程上下问类加载器(Thread Context ClassLoader): 可实现父类加载器请求子类加载器完成类加载。
第8章 虚拟字节码执行引擎
运行时栈帧结构
局部变量表: 变量值存取空间,存放方法参数和方法内部定义的局部变量。局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。不会初始化默认值。
操作数栈:后入先出栈,由字节码指令往栈中存数据和取数据,栈中的任何一个元素都是可以任意的Java数据类型。
动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有该引用是为了支持方法调用过程中的动态连接。
方法返回地址:存放调用调用该方法的pc计数器的值。当一个方法开始之后,只有两种方式可以退出这个方法:1、执行引擎遇到任意一个方法返回的字节码指令,也就是所谓的正常完成出口。2、在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种方式成为异常完成出口。正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法正常退出时,调用者的pc计数器的值作为返回地址,而通过异常退出的,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。本质上,方法的退出就是当前栈帧出栈的过程。
方法调用
静态分配
所有依赖静态类型来定位方法执行版本的分派成为静态分派,发生在编译阶段,典型应用是方法重载。
动态分配
在运行期间根据实际类型4来确定方法执行版本的分派成为动态分派,发生在程序运行期间,典型的应用是方法的重写。
多分配与单分配
Java是一门静态多分配,动态单分配的语言。
JVM实现动态分派
动态分派在Java中被大量使用,使用频率及其高,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此JVM在类的方法区中建立虚方法表(virtual method table)来提高性能。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类该方法的地址入口一样,即子类的方法入口指向父类的方法入口。如果子类重写父类的方法,那么子类的虚方法表中该方法的实际入口将会被替换为指向子类实现版本的入口地址。虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
8.4基于栈的字节码解释执行引擎
基于栈的指令集与基于寄存器的指令集
Java编译器输入的指令流基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。另外一种指令集架构则是基于寄存器的指令集架构。最直接的区别是,基于栈的指令集架构不需要硬件的支持,而基于寄存器的指令集架构则完全依赖硬件,这意味基于寄存器的指令集架构执行效率更高,单可移植性差,而基于栈的指令集架构的移植性更高,但执行效率相对较慢,初次之外,相同的操作,基于栈的指令集往往需要更多的指令。
第9章 类加载以及执行子系统的案例与实战
Tomcat:正统的类加载器架构
OSGi:灵活的类加载器架构
OSGi(Open Service Gateway Initiative) 是 OSGi 联盟(OSGi Alliance)制定的一个基于 Java 语言的动态模块化规范。
OSGi 在提供强大功能的同时,也引入了额外的复杂度,带来了线程死锁和内存泄露的风险。
第四部分 程序编译与代码优化
第10章 早期(编译器)优化
javac编译器
Sum Javac编译过程:
1.解析与填充符号表
2.插入式注解处理器的注解处理过程
3.分析与字节码生成过程
Java语法糖
语法糖(Syntactic Sugar),也称糖衣语法,指在计算机语言中添加的某种语法,这种语法对语言本身功能来说没有什么影响,只是为了方便程序员的开发,提高开发效率。说白了,语法糖就是对现有语法的一个封装。
Java中的语法糖主要有以下几种:
1. 泛型与类型擦除
2. 自动装箱与拆箱,变长参数、
3. 增强for循环
4. 内部类与枚举类
泛型与类型擦除
Java泛型只在程序源码中存在,在编译后的字节码文件中替换为原生类型(Raw Type,也称裸类型),且在相应位置插入强制转型代码。
自动装箱、拆箱与遍历循环
自动装箱。拆箱在编译之后被转化成了对应的包装盒还原方法。
循环遍历还原成了迭代器的实现,这也是循环遍历需要被遍历的类实现Iterable接口的原因。
变长参数 使用有两个条件,一是变长的那一部分参数具有相同的类型,二是变长参数必须位于方法参数列表的最后面。变长参数同样是Java中的语法糖,其内部实现是Java数组。
自动装箱陷阱:包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱, 以及它们equals()方法不处理数据类型转换的关系。
条件编译
Java的条件编译,也是语法糖,根据条件值的布尔值的真假,编译器把分支中不成立的代码去掉,这一阶段在编译器接触语法糖的阶段完成。
实战:代码格式扫描
第11章 晚期(运行期)优化
Java程序在运行的期间,可能会有某个方法或者代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时JVM会将这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,JIT编译器)。
热点代码
运行过程中会被即时编译器编译的“热点代码”有两类:
1、被多次调用的方法;
2、被多次执行的循环体。
对于第一种情况,由于是由方法调用触发的编译,因此编译器会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式。而对第二种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法作为编译对象,这种编译方式因为编译发生在方法执行过程之中,因此形象地被称为栈上替换,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了。
触发条件
主要的热点探测判定方法有两种:
- 基于采样的热点探测:虚拟机会周期性地检查各种线程的栈顶,如果发现某个或者某些方法经常出现在栈顶,那这个方法就是“热点方法”。
优点:实现简单、高效,还可以很容易地获取方法调用方法。
缺点:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。 - 基于计数器的热点探测:虚拟机会为每个方法尽力计数器,统计方法的执行次数,如果执行次数超过一定的阙值就认为它是热点方法。
优点:统计结构相对来说更加精确与严谨。
缺点:实现起来麻烦,需要为每个方法及建立并维护计数器,不能直接获取到方法的调用关系。
在HotSpot中使用的是第二种方法,基于计数器的热点探测法,因此它为每个方法准备了两类计数器:方法调用计数器和汇编计数器(统计一个方法中循环体代码执行的次数,在字节码中中遇到控制流向后跳转的指令称为“回边”。建立回边计数器的目的是为了触发OSR编译。)。
编译优化技术
公共子表达式消除: 如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那E的这次出现就成公共子表达式,可以用原先的表达式进行消除。
数组边界检查消除:系统将自动进行上下界的范围检查。隐式异常处理:Java中空指针和算术运算中除数为零的检查。此外还有:自动装箱消除、安全点消除、消除反射等等。
方法内联:把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用。
逃逸分析:分析对象的动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递给其他方法,称为方法逃逸。甚至还有可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问到的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,就可以为这个变量进行一些高效的优化:如:栈上分配、同步消除、标量替换等。
第五部分 高效并发
第12章 Java内存模型与线程
硬件的效率与一致性
在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存(Main Memory),如上图所示。当多个处理器的运算任务都涉及到主内存中的同一块区域,那么将高速缓存中的数据同步回主内存时,为了保证数据的一致性,需要各个处理器访问缓存时都遵循一些协议,即缓存一致性协议。
Java内存模型
Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
主内存与工作内存
Java内存模型的主要目标:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。注意,此处的变量与Java编程语言中所说的变量有所区别,它包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
主内存:Java内存模型规定了所有的变量都存储在主内存中,此处的主内存仅仅是虚拟机内存的一部分,而虚拟机内存也仅仅是计算机物理内存的一部分(为虚拟机进程分配的那一部分)
工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值),都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
内存间的交互操作
虚拟机实现时必须保证下面提及的每一种操作都是原子操作。
lock(锁定):作用于主内存的变量,它把一个变量标志为一条线程独占的状态。
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋给工作内存的变量。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
write(写入):作用于主内存中的变量,它把store操作从主内存中得到的变量值放入主内存的变量中。
long和double类型的特殊规则:java虚拟机允许将64位数据的读写操作划分为两次32位的操作。
volatile关键字的作用
1.保证了新值能立即存储到主内存,每次使用前立即从主内存中刷新。
2.禁止指令重排序优化。
注:volatile关键字不能保证在多线程环境下对共享数据的操作的正确性。可以使用在自己状态改变之后需要立即通知所有线程的情况下。
原子性:
在java内存模型来直接保证的原子性变量操作包括read、load、assign、use、stroe、write,我们大致可以认为基本数据类型的访问读写是具备原子性的(例外就是long和double的非原子性协定)
可见性:
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。
除了volatile之外,java还有两个关键字能实现可见性,即synchronized和final。
有序性:
如果在本线程内观察,所有的操作都是有序的,表现为线程内串行的语义;如果在一个线程中观察另一个线程,所有的线程都是无序的,表现为指令重排序现象和工作内存与主内存同步延迟现象。
volitile关键字本身就包含了禁止指令重排序的语义,而synchronized的规则“一个变量在同一个时刻只允许一条线程对其进行lock操作”决定了持有同一个锁的两个同步块只能串行地进入。
先行发生原则
两项操作之间的偏序关系。如果操作A先于操作B发生,那么操作A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。
先行发生规则:
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的结论
Java与线程
实现线程
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现
内核线程就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。是1:1的关系
用户线程指完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。是1:N的关系
内核线程和用户线程混合使用,推荐。是N:M的关系
线程调度
系统为线程分配处理器使用权主要有两种调度方式:协同式线程调度和抢占式线程调度。java使用的是抢占式,可以通过分配优先级来控制。
线程状态
1. 新建状态(New):新创建了一个线程对象。
2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
(一)、等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。
(二)、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
(三)、其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
5. 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
第13章 线程安全与锁优化
线程安全
线程安全定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的;
java 语言中的线程安全
1. 不可变:
不可变的对象一定是线程安全的。如String类。
2. 绝对线程安全
3. 相对线程安全
4. 线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用;
5. 线程对立
指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码;
线程安全的实现方法
1、互斥同步
互斥同步:是常见的并发正确性保障手段;
同步:是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻被一个线程使用。
互斥:互斥是实现同步的一种手段;临界区,互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的;
(1)最基本的互斥同步手段就是 synchronized关键字:synchronized关键字经过 编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这个两个字节码指令,这两个字节码都需要一个 reference类型的参数来指明要锁定和解锁的对象;如果java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据 synchronized修饰的实例方法还是类方法,去取对应的对象实例或Class 对象来作为锁对象;(最基本的互斥同步手段就是 synchronized关键字)
根据虚拟机规范的要求:在执行monitorenter指令时,如果这个对象没有锁定或当前线程已经拥有了那个对象的锁,锁的计数器加1,相应的,在执行 monitorexit 指令时会将锁计数器减1;当计数器为0时,锁就被释放了;
synchronized同步块对同一条线程来说是可重入的, 不会出现自己把自己锁死的问题;同步块在已进入的线程执行完之前,会阻塞后面其他线程 的进入;
(2)java.util.concurrent 包中的重入锁(ReentrantLock)来实现同步
synchronized 和 ReentrantLock 的区别: 一个表现为 API 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成),另一个表现为 原生语法层面的互斥锁;
ReentrantLock增加了一些高级功能:主要有3项:等待可中断,可实现公平锁, 锁可以绑定多个条件;
等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助;
公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;
锁绑定多个条件:指一个 ReentrantLock对象可以同时绑定多个 Condition对象,而在 synchronized中,锁对象的wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock 则无需这样做,只需要多次调用 newCondition() 方法即可;
2、非阻塞同步
阻塞同步(互斥同步)的问题:就是进行线程阻塞和唤醒所带来的性能问题,互斥同步属于一种悲观的并发策略,无论共享数据是否真的会出现竞争,它都要进行加锁,用户态核心态转换,维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
非阻塞同步定义:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为 非阻塞同步;
3、无同步方案
如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的;
第一类线程安全代码——可重入代码:也叫作纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误; 所有的可重入代码都是线程安全的;
如何判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的;
第二类线程安全代码——线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能够保证在同一线程中执行? 如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也可以保证线程间不出现数据争用问题;
锁优化
1.适应性自旋
自旋锁:当两个或两个以上的线程同时并行执行,让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有所的线程是否能很快释放锁。
缺点:自旋的线程只会白白消耗处理器资源,带来性能上的浪费。故需要使等待时间有一定的限度
改进:等待时间要自适应。等待时间随着程序运行和性能监控信息的不断完善。
2.锁消除
锁消除:虚拟机及时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
判断依据:源于逃逸分析的数据支持。若堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上的数据看待。
3.粗粒化
出现的问题:如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
措施:如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,【将会把加锁同步的范围扩展到整个操作序列外部】。
4.轻量级锁
HotSpot虚拟机的对象头分为两部分信息:
第一部分:用于存储对象自身的运行时数据,如哈希码,GC分代年龄等;这部分数据的长度在32位和64位的虚拟机中分别为 32bit 和 64bit,官方称它为 Mark Word,它是实现轻量级锁和偏向锁的关键;(Mark Word 是实现轻量级锁和偏向锁的关键)
第二部分:用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度;
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会工具对象的状态复用自己的存储空间;
加锁过程:
(1)如果此同步对象没有被锁定(锁标志位为01状态):虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录的空间,用于存储对象目前的Mark Word 的拷贝;
(2)然后,虚拟机将使用CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record的指针;
(3)如果这个更新工作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为 00,即表示 此对象处于轻量级锁定状态;
(4)如果这个更新失败了,虚拟机首先会检查对象的Mark Word 是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象以及被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为 10,Mark Word中存储的就是指向重量级(互斥量)的指针,后面等待锁的线程也要进入阻塞状态;
解锁过程:
(1)如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS 操作把对象当前的Mark Word 和 线程中复制的 Dispatched Mard Word替换回来;
(2)如果替换成功,整个同步过程就over了;
(3)如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程;
结论:
轻量级锁能提升程序同步性能的依据是: 对于绝大部分的锁,在整个同步周期内都是不存在竞争的;
如果没有竞争,轻量级锁使用CAS 操作避免了使用互斥量的开销;但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS 操作,因此在有竞争的case下, 轻量级锁会比传统的重量级锁更慢;
5.偏向锁
偏向锁的目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能;
如果说轻量级锁是在无竞争的情况使用CAS 操作去消除同步使用的互斥量:那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS 操作都不做了;
偏向锁的偏: 它的意思是这个锁会偏向于 第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步;
偏向锁的原理:若当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为01, 即偏向模式;同时使用CAS 操作把获取到这个锁的线程的ID 记录在对象的 Mark Word之中,如果 CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作;
当有另一个线程去尝试获取这个锁时,偏向模式就结束了:根据锁对象目前是否处于被锁定的状态, 撤销偏向后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行;
结论:
偏向锁可以提高带有同步但无竞争的程序性能;
如果程序中大多数的锁总是被多个不同的线程访问:那偏向模式是多余的;