前言

针对《深入理解Java虚拟机》这本书的读书笔记,主要包括以下内容:

  • 内存与GC
  • 类加载与编译
  • 并发与线程安全

内存与GC

内存相关

在Java中,运行时数据区可以分为以下几种:

  • 线程共享的数据区,GC负责回收的区域
    • 方法区:主要存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,通常称作永久带
      • 运行时常量池:方法区的一部分,存放编译期生成的各种字面量和符号引用,在运行期也可能将新的常量放入常量池(比如String.intern()方法)
    • 堆:Heap区,内存中最大的一块区域,专门用于存放对象实例(包括数组)和成员变量(包括基本类型),GC负责的主要目标。根据GC的特性可以划分为老年代和新生代,其中新生代又可以划分为Eden区、From Survivor区、To Survivor区
  • 线程隔离的数据区,随线程而生,随线程而灭
    • 虚拟机栈:用于存放帧栈、局部变量表等
      • 帧栈:当一个方法被执行时会相应的创建的一个帧栈,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,方法开始执行时会把该帧栈入栈到虚拟机栈中,方法执行完毕后会出栈
      • 局部变量表:存放各种基本类型数据、对象引用Reference(对象实例本身还是存到堆区中的),以Slot为基本分配单位
    • 本地方法栈:与虚拟机栈作用相似,虚拟机栈负责的是Java方法,本地方法栈负责的是Native方法,两者统称Stack区
    • 程序计数器:占据区域很小,作为当前线程所执行的字节码的行号指示器

一个对象在内存中存储的布局可以分为3部分:

  • 对象头(Header):存放哈希码,GC分代年龄、类型指针等信息
  • 实例数据(Instance Data):对象真正记录的有效信息
  • 对齐填充(Padding):对象要求大小必须是8字节的整数倍,当大小不符合时会做额外填充来补全

对象通过reference来定位堆上实际位置有两种方式:

  • 句柄访问:保证了reference的稳定,reference存放句柄地址,然后从句柄池中取句柄,读取其中实例的地址信息
  • 直接指针访问:速度快,reference直接指向实例的地址,HotSpot目前使用的方式

GC相关

这部分分为如何判断对象可被回收如何执行回收两个问题来讲

判断对象可被回收的方法:

  • 引用计数法:每个对象都伴随着一个计数器,当该对象被引用时计数器值加一,当引用失效时值减一,减为0时说明该对象可被回收。由于引用计数法无法识别循环引用的问题(即A、B两对象互相持有对方的引用,引用计数不为0,但他们已经无其他引用,属于不可用状态了),所以虚拟器没有采用这种方案
  • 可达性分析法:目前商用虚拟机采用的方案,以一个GC Root对象作为起点,循着引用链做一轮完整遍历,未被遍历到的对象即可回收对象。该方法要求GC Root对象是一个保证存活的对象,通常用以下对象作为GC Root:
    • 虚拟机栈中(栈帧中的本地变量表)的引用对象
    • 方法区中类静态属性引用对象
    • 方法区中类常量引用对象
    • 本地方法栈JNI引用的对象

执行回收的方法:

  • 标记-清除法:最基础的回收方法,经过一轮可达性分析法遍历后标记出所有可回收对象,然后回收所有被标记过的对象,处理效率不高,且会产生内存碎片。
  • 复制法:将当前内存一分为二,只使用其中一半内存,当空间占满后将所有存活的对象复制到另一半空间上,并清空当前这一半空间,如此反复。这种方法提高了效率,也解决了内存碎片的问题,但每次只能使用一半的内存空间,相当于浪费了一部分内存。这种方案比较适合存活少,垃圾多的场景,可以每次复制较少的对象,适合新生代。
  • 标记-整理法:与标记-清除法类似先做一轮遍历标记,但是之后不做回收,而是将所有存活对象都向一端移动,集中在一起,解决了内存碎片的问题,然后再将存活对象边界以外的空间清空。这种方案比较适合存活多,垃圾少的场景,每次只要标记少量的垃圾,适合老年代。
  • 分代收集法:根据上面的分析,现代虚拟机将内存空间划分为新生代和老年代两块,根据各自的特点选用合适的回收方法,以达到最可观的效率。

GC类型:

  • Minor GC:新生代的GC,频率高
  • Major GC:老年代的GC,频率低,通常在数次Minor GC后出现
  • Full GC:发生了Stop-The-World的GC,停顿时间稍长

了解了以上内容后,最后完整梳理一下分代收集法的具体过程以及其中的策略:

  • 在新生代中区域又划分为Eden,Survivor A,Survivor B三块区域,比例大致为8:1:1。
  • 每次创建新实例时会由Eden中提供空间,待Eden充满后执行一次Minor GC(复制法),将存活对象复制到Survivor A中,然后Eden清空后继续对外提供空间,再次充满后将Eden与Survivor A中存活的对象一起复制到Survivor B,同时清空Eden和Survivor A,再次充满后将Eden与Survivor B中存活的对象一起复制到Survivor A,如此反复。
  • 虚拟机给每个对象都分配了一个年龄计数器,对象在Survivor区中每”熬过”一次Minor GC后仍存活则年龄加一,当年龄到达15后对象会被晋升到老年区中。
  • 当老年区充满后则会执行一次Major GC(标记-整理法)

以上就是分代收集法的完整过程,除了上面描述的常规过程以外,实际过程中还存在一些额外策略:

  • 大对象直接进入老年代:比如很长的字符串以及数组
  • 动态年龄判定:如果在Survivor空间中,相同年龄所有对象大小的总和大于Survivor空间的一半,即时年龄计数没有达到15,也会直接进入老年代
  • 空间分配担保:在Minor GC发生前会先检查老年代最大连续可用空间是否大于新生代所有对象总空间,这是为了预防新生代对象全部处于存活状态,导致老年代无法提供足够的空间,导致Minor GC后紧接着又执行Full GC的情况。如果虚拟机当前策略是不允许“冒险”的话,则省去Minor GC直接执行Full GC,以防这种意外发生。

虚拟机提供了参数配置文件,使我们可以根据自己的需求修改一些上面提到的GC策略:

例如:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

Params Des
Xms 最小堆容量
Xmx 最大堆容量
Xmn 新生代容量,Eden + Survivor A + Survivor B
PrintGCDetail 记录GC日志
SurvivorRatio 新生代中Eden所占的比例,设置为8意味着占据80%,两个Survior平分剩余空间,各占10%
PretenureSizeThreshold 对象大于该大小,直接分配到老年代
MaxTenuringThreshold 大于该年龄时晋升到老年代,默认15

引用的类型:

强引用:new创建的对象产生的引用,只要存活直至OOM也不会被回收
软引用:OOM前会回收该引用指向的对象
弱引用:下一次GC时会回收该引用指向的对象
虚引用:没有引用对象的功能,仅在关联的对象被回收时收到一个通知

类加载与编译

类加载

触发类发生加载(初始化)的场景:

  • 遇到new,getstatic,putstatic,invokestatic4条字节码指令时
  • 使用java.lang.reflect包反射调用时
  • 子类初始化时会首先初始化其父类
  • 虚拟机启动时,会初始化指定的包含main方法的主类
  • JDK1.7动态调用解析出的方法句柄是REF_getStatic, REF_putStatic, REF_invokeStaticde时

类加载的生命周期按顺依次包括:

  • 加载:首先根据类的全限定名来获取类的二进制字节流,将字节流代表的静态存储结构转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的java.lang.Class对象作为访问方法区数据的入口
  • 验证:防止恶意代码危害虚拟机的安全,包括文件格式验证、元数据验证、字节码验证、符号引用验证几部验证
  • 准备:正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中分配(注意仅是被static修饰的类变量不是实例变量,实例变量会在代码执行时在堆区分配)
  • 解析:虚拟机将常量池内的符号引用替换为直接引用的过程,在运行时绑定的场景下可能会发生在初始化之后
  • 初始化:执行执行类构造器<clinit>()方法的过程,父类的<clinit>()会优先执行。<clinit>()方法会执行static块及static块之前的static变量的赋值动作
  • 使用
  • 卸载

以上步骤中的加载阶段的根据类的全限定名来获取类的二进制字节流这部分是由类加载器完成的,类加载器分以下几种:

  • BootstrapClassLoader:它不继承 ClassLoader,而是由 JVM 内部实现,负责加载$JAVA_HOME/jre/lib 里的核心 jar 文件
  • ExtensionClassLoader:继承自ClassLoader,负责加载 $JAVA_HOME/jre/lib/ext 目录下的 jar 文件,我们把自己写的jar放到这个目录下也会由ExtensionClassLoader来加载
  • AppClassLoader:继承自ClassLoader,负责加载我们在项目中编写的类
  • 自定义ClassLoader:如果我们想动态加载一些类文件(本地文件/网络下载),则需要自定义类加载器,继承自ClassLoader,复写其中的findClass()方法

对于任何一个类,都需要由加载它的类加载器和这个类一同确立其在java虚拟机唯一性,每个类加载器都有类名称空间。同一个Class文件,被不同的类加载器加载,会得到两个不同的类,这会影响到equalsinstanceof isAssignalbeFromisInstance方法的返回结果。

然后我们来分析一种场景,假如我们自己在工程中创建了一个java.lang.String类,这个类被AppClassLoader所加载,而Java又自带了被BootstrapClassLoader加载的java.lang.String,根据上面对唯一性的解释,由不同的加载器加载会产生不同的类,而这两个类的全限定名又完全一致,这样不就打破类的唯一性了么?所以类加载器设计了双亲委托机制来解决这个问题。

双亲委托机制:类加载器在加载一个类时会优先委托它的parent类加载器来做加载,无法加载时才会自己加载。parent的级别次序为:Bootstrap ClassLoader > Extension ClassLoader > App ClassLoader > Custom ClassLoader

以为App ClassLoader为例,它在加载一个类时会先委托Bootstrap ClassLoader去加载,无法加载则接着交给Extension ClassLoader去加载,最后才会自己加载,很像设计模式里的责任链模式。在ClassLoader的源码的核心方法loadClass中我们就能看到双亲委托机制的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
//首先检查该类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//从父加载器加载
c = parent.loadClass(name, false);
} else {
//如果没有父加载器,则说明当前加载器是bootstrapClassLoader
//通过bootstrapClassLoader加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
//如果父加载器均不能完成加载,则在findClass方法里自己完成加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}

梳理一下源码中的加载过程:

  • 首先判断当前class有没有被该类加载器加载过,如果加载过则直接返回曾经加载的类
  • 没加载过,则开始走双亲委托机制,递归的调用parent.loadClass交给最高层的loadClass来尝试加载
  • parent 加载器加载成功,则直接返回
  • parent 加载器全部未加载成功,则调用findClass()来加载

通过这种机制解决了一开始提出的加载多个java.lang.String的问题,当加载我们写的工程中的java.lang.String类时AppClassLoader会首先委托Bootstrap ClassLoader去加载,由于该加载器已经加载过JDK中的java.lang.String类了,会通过findLoadedClass方法直接返回已经加载过的类,这样就保证了类的唯一性。

根据上面的源码,如果我们需要自定义ClassLoader,只需要继承ClassLoader,重写它的findClass方法,在其中完成加载逻辑即可,而不要去重写loadClass方法,以保证双亲委托机制的正确执行。

方法调用

Java中的方法调用往往会经过解析与分派的过程:

  • 解析:解析方法的一系列修饰符,生成对应指令
  • 分派
    • 静态分派:依赖静态类型来定位方法执行版本的分派,编译期完成,经典应用是函数的重载
    • 动态分派:依赖实际类型来定位方法执行版本的分派,运行期完成,经典应用是函数的重写

举个栗子,有如下定义

1
2
3
4
class Man extends Human {
}
Human man = new Man();

在这个例子里,man这个实例的静态类型是Human,它的实际类型是Man,函数重载会由编译器完成而不是虚拟机,编译器会将它视作Human类型来选择重载函数。当Human中定义的方法被Man重写时,man在运行期调用该函数会以实际类型为依据,执行被Man重写的方法。

补充知识,函数重载的匹配优先级,char->int->long->float->double->Character->实现的接口类型->继承的父类类型->更上层的父类类型->Object->变长参数

怎么理解这个匹配优先级的关系呢?举个栗子就行了,如果方法调用传入的形参类型是long,但没有long类型的重载方法,有char,float,Character等类型的重载方法,那么long就会顺位选择float类型的重载方法,自动完成类型转化。

动态调用(MethodHandle)与动态代理(Proxy)

动态调用允许我们在运行时期使某个实例调用特定类型的方法,只要该实例确实具备该方法就能调用成功。举例来说,我们可以通过super轻松的调用父类的方法,但是对于父类的父类,也就是祖父类的方法就不方便调用了。这种时候可以依靠MethodHandle来指定类型为GrandFather执行动态调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Test {
class GrandFather {
void thinking() {
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking() {
System.out.println("i am father");
}
}
class Son extends Father{
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
Methodhandle mh = lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
mh.invoke(this);
} catch (Throwable e) {
}
}
public static void main(String[] args) {
(new Test().new Son()).thinking(); //输出i am grandfather
}
}
}

动态代理会生成一个代理类,每当执行该代理类的方法时都会触发invoke方法,这样我们就可以在执行方法前后插入我们自己额外的逻辑了

1
2
3
4
5
6
7
8
9
10
11
12
13
public <T> T create(final Class<T> service) {
return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
new InvocationHandler() {
private final Platform platform = Platform.get();
@Override public Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
insertPre();//在方法调用前插入的逻辑
//invoke调用原本定义的方法
return insertPost(method.invoke(this, args));//insertPost在得到调用结果后插入处理逻辑
}
});
}

类编译

Java中的语法糖有如下几种:

  • 泛型与类型擦除:编译器帮你实现安全的强制类型转换
  • 自动装箱、自动拆箱、遍历循环(foreach)、变长参数
  • 条件编译:执行不到的代码不会被编译成字节码

注意自动装箱拆箱的陷阱(还有这种操作?.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class 自动装箱陷阱 {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); //true
System.out.println(e == f); //false
System.out.println(c == (a + b)); //true
System.out.println(c.equals(a + b)); //true
System.out.println(g == (a + b)); //true
System.out.println(g.equals(a + b)); //false
}
}

运行期优化手段:

  • 内联、冗余存储消除、复写传播、消除无用代码
  • 公共子表达式消除:如果表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成了公共子表达式,直接用前面计算过的结果即可。
  • 数组边界检查消除:当在编译期能确定数据边界安全时,运行期就不再做边界安全判断
  • 方法内联:将方法中的逻辑添加到调用方法的地方,替代本次方法调用。避免了方法调用的开销(创建帧栈等),也为其他优化手段打下了基础。
  • 逃逸分析:当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸。甚至有可能被外部线程访问到,称为线程逃逸。如果能证明一个对象不会逃逸,就可以通过一些手段做优化。
    • 栈上分配:当对象不存在方法逃逸时,将对象占据的空间分配到当前方法的帧栈上而不是堆上,这样它能随帧栈销毁而销毁,减轻GC压力
    • 同步消除:当对象不存在线程逃逸时,也就说明不会发生读写竞争,可以省去为它做同步措施带来的开销
    • 标量替换:标量是指一个数据无法再分解成更小的数据来表示了,例如Java中的基本类型,反之则是聚合量,比如对象。当对象不存在方法逃逸时,可以将它拆解成标量,这样就可以存储在栈上,优势同栈上分配,也为后续优化创造了条件。

并发与线程安全

主内存:所有变量都存储在主内存
工作内存:每条线程都有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝
交互关系: Java线程 <=> 工作内存 <=> 主内存
主内存与工作内存间定义了8种交互操作,它们都是原子操作:

  • lock:作用于主内存变量,把一个变量标记为一条线程独占的状态
  • unlock:作用于主内存变量,解锁,之后才能被其他线程锁定
  • read:作用于主内存变量,把一个变量的值由主内存读取到工作内存
  • load:作用于工作内存变量,把读取到的值载入到工作内存的变量副本中
  • use:作用于工作内存变量,把工作内存中一个变量的值传递给执行引擎
  • assign:作用于工作内存变量,把一个从执行引擎接受到的值赋给工作内存中的变量
  • store:作用于工作内存变量,把一个变量的值由工作内存传递到主内存
  • write:作用于主内存变量,把store传来的值放到主内存的变量中
    其中read和load,store和write不允许单独出现,即不允许将值传递后变量却不接受。
    基本类型的读写是具备原子性的,其中long,double存在非原子性协定。

并发围绕的三大特性:

  • 原子性:之前提到的read、load、assign、use、store、write操作具备原子性,保证了绝对的同步,synchronized修饰后可保证原子性
  • 可见性:一个线程修改了共享变量的值其他线程能够立即得知这个修改,也就是新值会立刻同步到主内存,synchronized、volatile修饰后可保证可见性
  • 有序性:如果在本线程内观察,所有操作都是有序的,在一个线程中观察另一个线程,所有操作都是无序的。因为编译器做的指令重排优化仅能保证当前线程是串行的语义。synchronized、volatile修饰后可保证有序性,禁止了指令重排优化

对只保证了可见性和有序性的轻量级同步机制volatile而言,在不符合以下两条规则的场景中仍要使用synchronized或原子对象来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

线程调度类型:
协同式调度:执行时间由线程本身来控制,执行完成后主动通知系统调度另一个线程执行
抢占式调度:每个线程由系统来分配执行时间,Thread.yield()可以让出执行时间,Java采取的是该种调度方式,定义线程优先级可以“建议”系统给各个线程分配的时间多少

线程的状态:

  • 新建New:创建后尚未启动
  • 运行Runable:包括Running和Ready两种状态
  • 无限期等待Waiting:不会被分配CPU时间,只能等待被其他线程唤醒
  • 限期等待Timed Waiting:不会被分配CPU时间,一定时候后由系统唤醒
  • 阻塞Blocked:等待获取排他锁
  • 结束Terminated:已执行结束

将Java中各种操作共享的数据按照线程安全的安全程度由强到弱来排序,可以划分成5类:

  • 不可变:如final修饰的基本类型,最简单纯粹的线程安全
  • 绝对线程安全:达到“不管运行环境如何,调用者都不需要任何额外的同步措施”的类,实际很难达成
  • 相对线程安全:Java API提供的大部分线程安全类都属于这种类型,保证对这种对象单独的操作是线程安全的,不过在一些特性顺序的连续调用场景中会打破原子性,仍需要额外的同步手段
  • 线程兼容:对象本身不是线程安全的,但可以通过一些同步手段来保证安全,Java API中大部分类都是这种类型的
  • 线程对立:指无论是否采取同步措施,都无法在多线程环境中并发使用的代码,比较少见

实现线程安全的方法:

  • 互斥同步:传统的临界区、互斥量、信号量都是这种手段,Java中的实现方式是synchronized,也称为阻塞同步
  • 非阻塞同步:一种乐观的并发策略,先操作,当共享数据有争用发生冲突时再走补偿措施(不断重试直到成功)
  • 无同步方案:“可重入代码”、“线程本地存储”这些天生线程安全的代码才能采取这种措施

锁优化技术:

  • 自旋锁:等锁时通过忙循环(自旋)来不放弃CPU执行时间,如果等待的锁在短时间内释放的话可以避免一些挂起与恢复线程的开销,反之如果等待过长的话反而会浪费CPU时间,JVM会通过“自适应”来判断合适的自旋时机
  • 锁消除:当检测到一段代码不可能存在共享数据竞争时,如果其用到了锁则消除该锁。分析依据基于之前提到的“逃逸分析”
  • 锁粗化:当检测到一系列操作都是对一个对象反复加锁解锁,则会扩大同步范围(粗化)到包含这系列操作,这样只需一次加锁解锁就可以了,减少开销
  • 轻量级锁:将锁信息存储到对象头,而不借助传统的“重量级”锁机制,减少互斥量带来的性能开销。思想依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,但有两条以上的线程争用同一个锁时,轻量级锁就不再有效,需要膨胀为重量级锁,这时除了互斥量的开销外还额外增加了轻量级锁的开销。
  • 偏向锁:锁会偏向于第一个获得它的线程,如果在接下来的过程中锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再同步。和轻量级锁一样,它也是一个效益权衡性质的优化,不一定保证对程序运行总是有利的

最后

呼…总算写完了,通篇文章都是一种“大纲”的风格,注重列举和用简单的语言做概括。之所以专为这本书写了读书笔记也是因为这本书相对其他书要稍深入一些,通过记录一篇读书笔记可以加深自己的印象。由于一部分概述是自己对书中内容的理解的转述,如果有偏差也欢迎指出(๑•̀ㅂ•́)و✧

参考文章

《深入理解Java虚拟机》
深入理解Java虚拟机总结
深入理解JAVA虚拟机–读书笔记
Java 技术之类加载机制

声明:本站所有文章均为原创或翻译,遵循署名-非商业性使用-禁止演绎 4.0 国际许可协议,如需转载请确保您对该协议有足够了解,并附上作者名(Est)及原贴地址