概述:本文主要根据《深入理解java虚拟机》一书,对JVM的主要特性进行了归纳总结,内容涵盖JVM运行时内存区域的划分、垃圾回收的基本原理与算法、内存分配与回收的基本策略、虚拟机类加载机制、程序编译与代码优化、Java内存模型与线程、线程安全与锁优化等。
第一部分 JVM运行时内存划分
Java虚拟机在执行Java程序时将所管理的内存划分为若干个不同区域:虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(Program Counter Register)、方法区(Method Area)、堆(Heap)
![JVM内存划分](http://i.imgur.com/fMwCNQt.png)
1.1 程序计数器
(1)作用:记录正在执行的虚拟机字节码指令的地址。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响。
(2)异常:此内存区域是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况的区域。
1.2 虚拟机栈
(1)作用:存储局部变量表、方法出口等信息。每个方法在执行的同时会创建一个栈帧,方法的调用与退出,对应着栈帧在虚拟机栈中的入栈与出栈
(2)异常:如果线程请求的栈深度大于虚拟机栈所允许的栈深度,将抛出StackOverFlowError异常;如果虚拟机栈可以动态扩展、且扩展时无法申请到足够的内存,会抛出OutOfMemoryError异常。
1.3 本地方法栈
(1)作用:java虚拟机栈为虚拟机执行java方法服务,本地方法栈为虚拟机执行Native方法服务。
(2)异常:本地方法栈也会抛出StackOverFlowError和OutOfMemoryError异常
1.4 堆
(1)作用:存放对象实例以及数组。
(2)异常:堆可以处于物理上不连续的区间,堆扩展失败时会抛出OutOfMemoryError异常。
(3)细分:从内存回收的角度看,堆可以细分为新生代和老年代,采用分代收集算法。
1.5 方法区
(1)作用:存储被虚拟机加载的类信息、常量、静态变量等。方法区也被称为永久代
(2)异常:无法满足内存分配要求时,将抛出OutOfMemoryError异常。
运行时常量池
(1)作用:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用
(2)异常:申请内存失败时抛出OutOfMemoryError异常。
总结
(1)虚拟机栈、本地方法栈、程序计数器属于线程私有,堆、方法区属于线程共享;
(2)堆、方法区可能抛出OutOfMemoryError,虚拟机栈、本地方法栈可能抛出OutOfMemoryError和StackOverFlowError异常;
(3)程序计数器没有规定OutOfMemoryError异常。
第二部分 判断对象是否存活
2.1 引用计数法(JVM不采用)
(1)概述:给对象添加一个引用计数器,每当有一个地方引用它时,计数器加一;当引用失效时,计数器减一。计数器为0的对象就是不可能再被使用的。
(2)缺点:这种方法很难解决对象之间互相循环引用的问题。
public class Main1 {
public Object instance = null;
public static void testGC() {
Main1 obj1 = new Main1();
Main1 obj2 = new Main1();
// 互相引用
obj1.instance = obj2;
obj2.instance = obj1;
// 对象引用赋null
obj1 = null;
obj2 = null;
// 发生垃圾回收
System.gc();
}
}
2.2 可达性分析(java等主流语言所采用)
概述:通过一系列称为“GC Roots”的对象作为起点,从这些节点开始往下搜索,当一个对象到GC Roots没有路径可达时,证明此对象是不可用的,将被判定为可回收对象。
2.3 java的四种引用
概述:JDK1.2后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),强度依次减弱。
(1)强引用
指类似于Object obj = new Object()这类普遍存在的引用,只要强引用存在,该对象永远不会被回收。
(2)软引用
软引用关联的对象,在系统将要发生内存溢出之前,将会把这些对象列入回收范围进行第二次回收。
(3)弱引用
被弱引用关联的对象只能生存到下一次垃圾收集发生之前,垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
(4)虚引用
虚引用不会对对象的生存时间构成影响,唯一作用就是在该对象被回收时收到一个系统通知。
第三部分 垃圾收集算法
3.1 标记-清除算法(Mark-Sweep)
(1)概述:算法分为“标记”和“清除”两个阶段:首先标记出所有需要被回收的对象,然后统一回收所有被标记的对象。
(2)不足:一个是效率问题,标记和清除两个过程效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片。
3.2 复制算法(Coping)——主要用于回收新生代
(1)概述:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块内存用完后,将还存活的对象复制到另一块上面,然后将当前使用的内存空间一次性清理掉。这样每次都是对整个半区进行回收,不用考虑内存碎片等复杂情况,简单高效。
(2)不足:将内存缩小为了原来使用的一半。
(3)改进:为了提高内存使用率,可以将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一个Survivor。回收时,将Eden和survive1中的存活对象复制到survive2中,然后清理掉Eden和survive1,这样每次只有一个survive空间被浪费。当survive空间不够时,这些对象通过分配担保的机制直接进入老年代。
(4)适用场景:复制算法在对象存活率较高时需要进行较多的复制操作,效率会很低,所以一般在新生代使用,不在老年代使用。
3.3 标记-整理算法(Mark-Compact)——用于老年代
(1)概述:标记-整理算法首先对可回收的对象进行标记,然后让所有存活对象都向一端移动,最后清理掉端边界以外的内存。
(2)适用场景:标记-整理算法在对象存活率较高时效率较高,所以一般用于老年代的垃圾回收。
第四部分 内存分配与回收策略
概述:对象的内存分配就是在堆上分配,对象主要分配在新生代的Eden区,少数情况下直接分配在老年代中。
4.1 垃圾收集动作
(1)新生代GC(Minor GC):发生在新生代的垃圾收集动作,大多数java对象朝生夕灭,所以Minor GC非常频繁,回收速度也比较快。
(2)老年代GC(Major GC / Full GC):发生在老年代的GC,经常伴随有Minor GC,Major GC的速度一般比Minor GC慢10倍以上。
4.2 内存分配策略
(1)对象优先在Eden分配
大多数情况下对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
(2)大对象直接进入老年代
大对象是指需要大量连续内存空间的对象,经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集操作。虚拟机提供特定参数,令大于这个设置值的对象直接在老年代分配。
(3)长期存活的对象将进入老年代
虚拟机给每个对象定义一个对象年龄计数器,对象在新生代的survivor区中每熬过一次Minor GC年龄就增加1岁,当年龄增加到一定程度(默认为15岁),就会被晋升到老年代。
动态对象年龄判定:如果在survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。
4.3 回收方法区(永久代)
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
(1)废弃常量:废弃常量的回收与堆中对象的回收非常类似,若常量没有被引用,则在垃圾回收时会被清理出常量池
(2)无用的类:类需要同时满足以下三个条件才算是“无用的类”
1) 该类的所有实例已经被回收
2) 加载该类的ClassLoader已经被回收
3) 该类的Class对象没有在任何地方被引用,无法在其他地方通过反射访问到该类。
第五部分 类的加载
5.1 类的生命周期
类的生命周期如下图所示,其中加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,而解析阶段在某些情况下可以在类的初始化阶段之后再开始:
5.2 类加载的过程
(1)通过一个类的全限定名来获取定义此类的二进制字节流;
(2)将字节流代表的静态存储结构转换为方法区的运行时数据结构;
(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的访问入口。
第六部分 类加载器
概述:对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。每一个类加载器都有一个独立的命名空间,比较两个对象是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。
6.1 系统提供的类加载器
(1)启动类加载器(Bootstrap ClassLoader):负责将存储在/lib目录下、且虚拟机识别的类库加载到虚拟机内存中;
(2)扩展类加载器(Extension ClassLoader):负责加载/lib/ext目录下的扩展类库,开发者可以直接使用扩展类加载器;
(3)应用程序类加载器(Application ClassLoader):也称为系统类加载器,是程序的默认类加载器,负责加载用户路径上所指定的类库。
6.2 双亲委派模型
(1)概述:类加载器之间的层次组合关系,称为类加载器的双亲委派模型(Parent Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父加载器。
(2) 工作过程:如果一个类加载器收到一个类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委派给父加载器去完成;每一层次的类加载器都是如此,直至顶层的启动类加载器;只有当父加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。
(3) 优点:java类随着它的类加载器一起具备了一种带有优先级的层次关系。
6.3 tomcat的类加载器架构
Tomcat有4组目录“/common/*”、“/server/*”、“/shared/*”、“/WEB-INF/*”
,把java类库放置在这些目录中的含义如下:
- /commom/目录:类库对Tomcat和所有web应用均可见,由CommonClassLoader加载;
- /server/目录:类库仅对Tomcat可见,由CatalinaClassLoader加载;
- /share/目录:类库仅对所有web应用可见,由SharedClassLoader加载;
- /WEB-INF/目录:类库仅对当前一个web应用可见,由WebAppClassLoader加载。
为了支持以上目录结构,Tomcat在双亲委派模型基础上自定义了多个类加载器,如图:
从委派关系可以看出,
CommonClassLoader
能加载的类都可以被
CatalinaClassLoader
和
SharedClassLoader
使用,而
CatalinaClassLoader
和
SharedClassLoader
自己能加载的类则与对方互相隔离;
WebAppClassLoader
可以使用
SharedClassLoader
加载到的类,但各个
WebAppClassLoader
实例之间互相隔离。
第七部分 Java语法糖
7.1 泛型与类型擦除
Java中的泛型只是在源码中存在,在编译后的字节码文件中已经替换为原来的原生类型了。因此,对于运行期的java语言来说,ArrayList与ArrayList是同一个类型。java语言中泛型实现方法称为泛型擦除。
// 泛型擦除前
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
System.out.println(map.get("hello"));
}
// 泛型擦除后
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
System.out.println((String)map.get("hello"));
}
7.2 自动拆、装箱
包装类的”==”运算在不遇到算术运算符的情况下不会自动拆箱,equals()方法不处理数据转型的关系。
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 128;
Integer f = 128;
Long g = 3L;
// Integer 类型的值在[-128,127] 期间,Integer 用 “==”是可以的 , Integer与 int类型比较(==)比较的是值。
System.out.println(c==d);// true
System.out.println(e==f);// false
System.out.println("--------------");
System.out.println(c==(a+b));// true,自动拆箱,按int型比较
System.out.println(g==(a+b));// true
System.out.println("**********************");
System.out.println(c.equals((a+b)));// true
System.out.println(g.equals((a+b)));// false, equals()不处理数据转型的关系
}
第八部分 逃逸分析
(1)基本概念:逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法内部被定义后,它可能被外部方法引用,称为方法逃逸;如果被其它线程访问到,称为线程逃逸。
(2)优势:如果能证明一个对象不会逃逸到方法或线程之外,可以进行如下优化:
1) 栈上分配。如果确定一个对象不会逃逸到方法之外,可以让这个对象在栈上分配内存,对象所占用内存空间就会随着栈帧出栈而销毁,减轻垃圾收集系统在堆内存上的工作压力;
2) 同步消除。如果能够确定一个对象不会逃逸到线程之外,那么对这个对象实施的同步措施就可以消除掉;
3) 标量替换。标量指无法再次被分解的,诸如原始数据类型;聚合量指可以继续分解的,诸如java中的类对象。对聚合量进行分解,将其成员变量用标量来替换,称为标量替换。如果能确定一个对象不会逃逸到方法之外,可以对该对象进行标量替换,替换后的标量可以直接存储在栈中。
(3)当前不成熟:
1) 如果要确定一个对象是否会逃逸,需要进行一系列复杂的数据流分析,这个过程消耗比较大,且逃逸对象所占比例大小不确定,收益未必大于消耗;
2)“栈上分配”方式目前实施起来比较复杂;
第九部分 java内存模型(Java Memory Model, JMM)
9.1 内存模型的划分
概述:Java内存模型规定所有变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Working Memory)。
主内存中保存实例变量、静态变量以及数组对象等线程公有对象,不保存线程私有的局部变量或方法参数等;线程的工作内存中保存了被该线程使用到的变量的主内存副本。
线程对变量的操作都必须在工作内存中进行,不能直接在主内存中进行;不同的线程也无法互相访问工作内存,线程之间的变量传递需要通过主内存。
9.2 内存间的交互操作
关于工作内存与主内存之间的交互,java内存模型定义了8种操作来完成,虚拟机在实现时必须保证这8种操作的原子性:
- lock:将主内存中的变量锁定,为一个线程所独占;
- unlock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量;
- read:将主内存中的变量值读到工作内存当中,以便随后的load操作;
- load:将read读取的值保存到工作内存中的变量副本中;
- use:将工作内存中的变量传递给执行引擎,每次使用到变量时都需要执行这个操作;
- assign:将执行引擎处理返回的值重新赋值给工作内存中的变量副本;
- store:将变量副本的值送到主内存中,以便随后的write操作;
- write:将store存储的值写入到主内存的共享变量当中。
每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。即其他线程的本地内存中的变量已经是过时的,并不是更新后的值。
第十部分 线程的实现
实现线程主要有3种方式:内核线程实现、用户线程实现、用户线程+轻量级进程混合实现。
10.1 使用内核线程(Kernel-Level Thread, KLT)
(1)基本概念
内核线程就是直接由操作系统内核支持的线程,由内核来完成线程的切换,通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般通过轻量级进程(Light Weight Process, LWP)来使用内核线程,轻量级进程即通常意义上的线程。每个轻量级进程都有一个内核线程支持,轻量级进程与内核线程之间1:1的关系称为一对一线程模型,如图:
(2)优缺点
- 每个轻量级进程都是一个独立的调度单元,单个轻量级进程阻塞不影响整体;
- 由于是基于内核实现,系统需要在用户态和内核态中来回切换,系统调用代价较高;
- 每个轻量级进程都需要一个内核线程的支持,要消耗一定的内核资源,使得系统支持的轻量级进程数目有限。
10.2 使用用户线程
(1)基本概念
狭义上的用户线程指完全建立在用户空间的线程库上,线程从创建到销毁不需要内核的帮助,不需要切换到内核态。这种进程与用户线程1:N的关系称为一对多线程模型,如图:
(2)优缺点
- 不需要内核的帮助,操作快速且低消耗;
- 不需要内核的帮助,线程阻塞处理、处理器映射等操作复杂,实现复杂度高。
10.3 使用用户线程+轻量级进程
(1)基本概念
用户线程完全建立在用户空间,使得线程的创建到销毁快速高效,支持并发;内核线程完成线程的调度和处理器映射。轻量级进程作为用户线程和内核线程之间的桥梁,用户线程与轻量级进程的N:M关系,称为多对多线程模型。
10.4 Java线程的实现
对于Sun JDK来说,使用一对一线程模型来实现,一个Java线程就映射到一个轻量级进程之中。
第十一部分 java线程调度
基本概念
线程调度指系统为线程分配处理器使用权的过程,主要有两种调度方式:协同式线程调度(Cooperation Threads-Scheduling)、抢占式线程调度(Preemptive Threads-Scheduling)。
11.1 协同式线程调度
(1)概念:线程执行时间由线程本身控制,线程自己工作执行完成后,主动通知系统切换到其他线程。
(2)优缺点:
- 实现简单,切换操作对线程自己可见,不存在线程同步问题;
- 线程执行时间系统不可控,可能发生阻塞。
11.2 抢占式线程调度
(1)概念:线程执行时间由系统分配,切换不是由线程本身决定。
(2)优缺点:
- 线程执行时间系统可控,不存在阻塞问题;
- 存在线程同步问题。
11.3 Java线程调度方式
Java线程调度使用“抢占式”,可以通过设置线程优先级来给某些线程多分配一点执行时间。
Java线程是通过映射到系统的原生线程上来实现的,所以线程的调度最终取决于操作系统。操作系统提供的线程优先级不一定能与Java线程优先级对应,操作系统优先级数目比Java少时,就会出现几个不同优先级的Java线程对应于操作系统相同优先级的线程。
另外,windows系统提供“优先级推进器”功能,大致作用是当系统发现一个线程执行得特别“勤奋努力”的话,可以越过线程优先级为它分配执行时间。所以,优先级可能被系统自行改变。
第十二部分 Java中线程安全实现方法
12.1 互斥同步(阻塞同步)
概述:互斥是实现同步的一种手段,互斥是方法,同步是目的。互斥的主要实现方式有临界区、互斥量、信号量。
(1)Synchronized
- Synchronized关键字经过编译后,会在同步块前后分别形成monitorenter和monitorexit两个字节码指令。
- 在执行monitorenter指令时,首先尝试获取对象锁。如果对象没有被锁定,或者当前线程已经拥有那个对象锁,则把锁的计数器加1;相应的,在执行monitorexit指令时,锁计数器减1。当计数器为0时,锁被释放。
- Synchronized同步块对同一条线程来说是可重入的,同步块在已经入的线程执行完之前,会阻塞后面其他线程的进入。Java的线程是映射到操作系统的内核上的,阻塞或唤醒线程需要系统在内核态与用户态之间切换,时间耗费大。所以,synchronized是Java语言中的一个重量级操作。
(2)ReentrantLock
ReentrantLock也具备线程重入特性,synchronized表现为原生语法层面的的互斥锁,ReentrantLock表现为API层面的互斥锁。与synchronized相比,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。
- 等待可中断。
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情 - 公平锁
公平锁是指多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序依次获得锁;非公平锁被释放时,任何一个等待的线程都有机会获得锁。Synchronized的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以在创建锁对象时设定为公平锁。 - 绑定多个条件
一个ReentrantLock对象可以同时绑定多个Condition对象。
12.2 非阻塞同步
互斥同步的最主要问题就是进行线程阻塞和唤醒所带来的性能问题,无论数据是否会出现竞争都会加锁,属于悲观的并发处理策略;非阻塞同步是先进行操作,如果没有其他线程争用共享数据则操作成功,如果数据争用产生冲突则不断尝试。
12.3 无同步方案
如果一个方法不涉及共享数据,则无需任何同步措施,以下两类代码天生具有线程安全性:
(1)可重入代码(Reentrant Code):在代码执行的任何时刻都可以中断它,转而去执行另一段代码,控制权返回后原来的程序不会出现任何错误。
可重入代码的特征:1) 不依赖存储在堆上的数据和公用的系统资源; 2) 用到的状态都由参数传入、不调用非可重入的方法。
(2)线程本地存储(Thread Local Storage):如果一段代码的共享数据能保证只在一个线程中执行(即不存在线程逃逸),则可以将共享数据的可见性限制在一个线程之内。
ThreadLocal类可用于生成共享数据的副本,创建线程私有的数据。
第十三部分 Java中锁优化
13.1 自旋锁与自适应锁
(1)自旋锁概念:在许多应用上,共享数据的锁定状态只会持续很短时间,为了这段时间去挂起和恢复线程并不值得。如果物理机有两个及以上处理器,能同时让两个或以上的线程同时并行执行,那么我们可以让后面请求锁的线程“稍等一下”,但不放弃当前处理器的执行时间,看看持有锁的线程是否很快会释放锁。为了让线程等待,只需让线程执行一个忙循环(自旋),这即是所谓的“自旋锁”。
(2)自旋锁不足:自旋等待不能代替阻塞,自旋等待虽然本身避免了线程切换的开销,但还是占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好,所以自旋等待的时间必须要有一定的限度。
(3)自适应锁概念:自适应自旋意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及拥有者的状态决定。如果在同一个锁对象上,自旋等待刚刚获得成功,并且持有锁的线程正在运行,那么虚拟机认为这次自旋很有可能再次成功,进而允许自旋持续更长时间。
13.2 锁消除
(1)概念:锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
(2)判定依据:锁消除的判定依据主要是逃逸分析,如果共享数据不存在线程逃逸,则可以进行锁消除。
13.3 锁粗化
概念:如果一系列的连续操作都是对同一个对象反复加锁和解锁,频繁地进行互斥同步操作会导致不必要的性能损耗。这种情况下,可以将加锁的范围粗化,避免反复加锁和解锁。