JVM—内存结构、垃圾回收、类加载与字节码技术

宋正兵 on 2021-04-01

内存结构

image88db7519d63eb6fe.png

程序计数器

Program Counter Register

  • 作用:存放下一条指令所在单元的地址的地方,物理上使用寄存器来实现的
  • 特点:
    • 线程私有
    • 唯一一个不会存在内存溢出的区域

虚拟机栈

Java Virtual Machine Stacks

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能由一个活动栈帧,对应着当前正在执行的那个方法

栈内存不需要进行垃圾回收。

栈内存划的大,方便更多次的方法调用,划的过大,会让线程数变少,因为物理内存是一定的。

方法内局部变量是线程私有的,不需要考虑线程安全,如果是公有的,需要考虑线程安全。

线程安全

判断一个变量是不是线程安全的,不仅要看他是不是方法内的局部变量,还要看他是否逃离了方法的作用范围,如method3。

1
2
3
4
5
6
7
8
9
10
11
12
// 多线程同时执行此方法
static void m1{
int x = 0;
for ( int i = 0; i<5000; i++){
x++;
}
System.out.println(x);
}
/**
* 线程1方法调用该方法,新建一个栈帧,每个线程私有int x作为局部变量,与其他线程不相互影响。
* 如果修改为static int x,线程1和线程2都要读取x自增后再写回,不加安全保护会产生线程安全问题。
**/
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
29
30
31
32
33
34
35
public class Demo1_1 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append(4);
sb.append(5);
sb.append(6);
new Thread(() -> {
// 与主线程共享sb
method2(sb);
}).start();
}

// 同上,线程安全
public static void method1() {
StringBuilder sb = new StringBuilder();
sb.append(1);
}

// 非线程安全,因为作为方法参数传递进来,因此可能会有其他线程访问,对其他线程共享,如main。需要使用StringBuffer。
public static int method2(StringBuilder s) {
s.append(1);
s.append(2);
s.append(3);
System.out.println(s.toString());
}

public static StringBuilder m3(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
// 其他线程可能拿到这个对象的引用,并发的修改。
return sb;
}
}

栈内存溢出

  • 栈帧过多导致栈内存溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo_2{
private static int count;
public static void main(String[] args){
try{
method1();
} catch (Throwable e){
e.printStackTrace();
System.out.println(count);
}
}
private static void method1(){
count ++;
method1();
}
}
// java.lang.StackOverflowError

​ 使用 -Xss256k 设置栈内存大小,使递归调用次数变小。

  • 栈帧过大导致栈内存溢出

即该区域可能抛出以下异常:

  • 当线程请求的栈深度超过虚拟机所允许的深度,即栈帧过多,会抛出 StackOverflowError 异常;
  • 虚拟机栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

线程运行诊断

案例1 :CPU 占用过高

定位问题

  1. 使用 linux 的 top 命令定位哪个进程对 cpu 的占用过高

    1
    top
  2. 使用 ps 查看进程的哪个线程占用率过高

    1
    ps H -eo pid,tid,%cpu | grep 进程ID
  3. 使用 jstack 命令查看有问题的线程,展示的线程 ID 为十六进制,可定位到问题代码的行数

    1
    jstack 进程ID

案例2 :程序运行很长时间没有结果

jstack 命令也能够检查出来 deadlock 死锁的存在。

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
static A a = new A();
static B b = new B();
public static void main(String[] args) throws InterruptedException{
new Thread(()->{
synchronized(a){
try{
Thread.sleep(2000);
} catch(InterruptedException e){
e,printStackTrace();
}
synchronized(b){
System.out.println("我获得了a和b");
}
}
}).start();
Thread.sleep(1000);
new Thread(()->{
synchronized(b){
synchronized(a){{
System.out.println("我获得了a和b");
}
}
}).start();
}
// deadlock 死锁
// 线程1先锁住a然后休眠2秒,在其休眠这段时间一秒后新线程2锁住了b,当线程2锁线程a时发现已经被锁了需要等待。再过一秒线程1醒过来,想要锁住线程b但是需要等待,于是死锁。

本地方法栈

Native Method Stacks,本地方法运行时候使用的内存。

本地方法:本地方法由其他语言如C或C++编写,编译成与处理器相关的机器代码。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowErrorOutOfMemoryError 异常。

Heap堆

通过 new 关键字,创建对象都会使用堆内存

  • 线程共享的,堆中对象都需要考虑线程安全的问题

  • 有垃圾回收机制,不再被引用的对象会被回收

如果在堆中没有足够的内存再去完成实例分配,并且堆也无法再扩展时,将会抛出 OutofMemoryError 异常。

堆内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args){
int i = 0;
try{
List<String> list = new ArrayList<>();
String a = "hello";
while (true){
list.add(a);
a = a + a;
i ++;
}
} catch {
e.printStackTrace();
System.out.println(i);
}
}
// java.lang.OutofMemoryError:Java heap space

可以使用 -Xmx8m 修改堆空间大小。

堆内存诊断

jps工具

查看当前系统中有哪些 java 进程

jmap工具

查看某一时刻堆内存占用情况

1
jmap -heap 进程ID

jconsole工具

是一个图形界面,多功能的监测工具,可以连续监测

方法区

image39668280b49515ce.png

image.png

【上面两张图的“常量池”应该是“运行时常量池”】

Method Area 方法区,也称非堆(Non-Heap),又是一个被线程共享的内存区域。方法区用于存放 class 的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等等。另外,方法区包含了一个特殊的区域“运行时常量池”。

当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

方法区内存溢出

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
// 1.8放在了元空间,1.8前放在永久代。元空间内存溢出,默认使用物理内存,不限制大小,因此默认不会看到溢出
// -XX:MaxMetaspaceSizer=8m 来设置元空间大小
// 可以用来加载类的二进制字节码
public class Demo1_8 extends ClassLoader {
public static void main(String[] args) {
int j = 0;
try {
Demo1_8 test = new Demo1_8();
for (int i = 0; i < 20000; i++, j++) {
//生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 版本号,public,类名,包名,父类,接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
//返回byte[]
byte[] code = cw.toByteArray();
// 执行类加载
test.defineClass("Class" + i, code, 0, code.length);
}
} finally {
System.out.println(j);
}
}
}
// 1.8 java.lang.OutOfMemoryError:Metaspace
// 1.6 java.lang.OutOfMemoryError:PermGen space

Java1.8使用 -XX:MaxMetaspaceSizer=8m 设置最大元空间大小。

Java1.8前使用 -XX:MaxPermSize=8m 设置最大元空间大小。

场景

动态加载类

  • Spring
  • MyBatis

spring aop中都是使用到了cglib这类字节码的技术,动态代理的类越多,就需要越多的方法区来保证动态生成的class可以加载入到内存中去。

运行时常量池

常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。

运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。

可以通过 javap -v 命令反编译 .class 文件查看。

StringTable

特性

String table 又称为 String pool,字符串常量池,其存在于堆中(jdk1.7之后改的),hashtable 结构,不能扩容。最重要的一点,String table 中存储的并不是 String 类型的对象,存储的而是指向 String 对象的索引,真实对象还是存储在堆中。

String table 还存在一个 hash 表的特性,里面不存在相同的两个字符串,延迟加载遇到没见过的才加进去

此外 String 对象调用 intern() 方法时,会先在 String table 中查找是否存在于该对象相同的字符串,若存在直接返回 String table 中字符串的引用,若不存在则在 String table 中创建一个与该对象相同的字符串。

  • 利用字符串常量池的机制,来避免重复创造字符串对象
  • 字符串变量拼接的原理是 StringBuilder(1.8)
  • 字符串常量拼接的原理是 编译器优化
  • 可以使用 intern() 方法,主动将字符串对象尝试放入字符串常量池当中
    • JDK 1.8 将这个字符串对象尝试放入字符串常量池,如果字符串常量池中有则不会放入,如过没有则会放入,无论成功与否都会把串池中的对象返回
    • JDK 1.6 将这个字符串对象尝试放入字符串常量池,如果字符串常量池中有则不会放入,如过没有则会将这个字符串对象复制一份放入串池,无论成功与否都会把串池中的对象返回
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// StringTable ["a", "b", "ab"]  hashtable结构 不能扩容
// 常量池中的信息,都会被加载到运行时常量池,这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new String("ab")
// 通过反编译可以发现s4经过了以下细节
// new StringBuilder().append("a").append("b").toString()
// toString() 方法实际是通过 new 关键字创建了一个字符串对象

// false
System.out.println(s3 == s4);


// 直接在常量池寻找"ab"和s3是一样的
// javac 在编译期间的优化,结果已经在编译期间确定为 ab,与s4的s1s2变量不相同
String s5 = "a" + "b";
// true
System.out.println(s3 == s5);

// s4为"ab",而"ab"已经存在于字符串常量池当中,直接返回字符串常量池当中的"ab"的引用(和s3相同)给s6
// intern 用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后 返回引用。
String s6 = s4.intern();
// true
System.out.println(s3 == s6);


String x2 = new String("c") + new String("d");
// StringTable ["c", "d"]
// 堆 new String("cd")

String x1 = "cd";
// StringTable ["c", "d", "cd"]
// 堆 new String("cd")

x2.intern();// StringTable中已经有了,入池失败
// false
System.out.println(x1 == x2);

// 如果将两行语句调换 1.8
String x2 = new String("c") + new String("d"); // 堆中
x2.intern(); // 池中没有,入池成功
String x1 = "cd"; // 常量池中有,直接取出来
System.out.println(x1 == x2); // true
// 1.6中副本入池,x2仍然是堆中cd,不等

存放位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// JDK 8下设置 -Xmx10m -XX:UseGCOvereadLimit
// JDK 6下设置 -XX:MaxPermSize=10m
public class Demo{
public static void main(String[] args){
List<String> list = new ArrayList<~>();
int i = 0;
try{
for (int j = 0; j< 26000; j++){
list.add(String.valueOf(j).intern());
i++;
}
} catch (Throwsable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
// 1.6 OutOfMemory: PerGen space
// 1.8 OutOfMemory: Heap space

在 JDK 1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区,此时 hotspot 虚拟机对方法区的实现为永久代

在 JDK 1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是hotspot中的永久代

在 JDK 1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace)

垃圾回收

当内存不足时,StringTable 中那些没有被引用的字符串仍然会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc*/
public class Solution {
public static void main(String args[]) {
int i = 0;
try {
for (int j = 0; j < 10000; j++) {
String.valueOf(i).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
// [GC (Allocation Failure) [PSYoungGen: 2048K->504K(2560K)] 2048K->664K(9728K), 0.0016202 secs] [Times: user=0.03 sys=0.02, real=0.00 secs]

性能调优

  1. 调整 hash 桶的个数。如果系统里字符串常量非常多,可以适当调大

    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
    // 设置桶大小 -XX:StringTable=桶个数
    // -XX:StringTable=20000 -XX:PrintStringTableStatistics
    public class Solution {
    public static void main(String args[]) {
    try (BufferedReader reader =
    new BufferedReader(
    new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
    String line = null;
    long start = System.nanoTime();
    while (true) {
    line = reader.readLine();
    if (line == null) {
    break;
    }
    line.intern();
    }
    System.out.println("cost" + (System.nanoTime() - start) / 1000000);
    } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }
    }
    // 200000 401ms
    // 1009 12000ms
  2. 考虑将字符串对象是否入池。如果应用里有大量的字符串而且可能会重复,则可以考虑让字符串入池减少堆内存个数

    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
    29
    30
    public class Solution {
    public static void main(String args[]) throws IOException {
    List<String> address = new ArrayList<>();
    System.in.read();
    for (int i = 0; i < 10; i++) {
    try (BufferedReader reader =
    new BufferedReader(
    new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
    String line = null;
    long start = System.nanoTime();
    while (true) {
    line = reader.readLine();
    if (line == null) {
    break;
    }
    // address.add(line); // 防止垃圾回收
    address.add(line.intern()); // 做一个入池动作,将串池内的加入到list,外的被垃圾回收掉
    }
    System.out.println("cost" + (System.nanoTime() - start) / 1000000);
    } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }
    System.in.read();
    }
    }

垃圾回收

如何判断对象可以回收

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。

判定效率高,但两个对象相互引用会导致内存泄漏,环形数据。

可达性分析算法

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。

如下图,对象 object 5、object 6、object 7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。

image.png

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

Memory Analyzer(MAT) 由 Eclipse 提供的 java 堆分析工具。

四种引用

强引用

强引用就是指在程序代码之中普遍存在的,类似 Object obj=new Object() 这类的引用。只有所有 GC Roots 对象都不通过强引用引用该对象,该对象才能被垃圾回收。

软引用

仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象。

软引用是通过 SoftReference 类实现的。

1
2
3
Object obj = new Object();
SoftReference softObj = new SoftReference(obj);
obj = null; // 去除强引用

这样就是一个简单的软引用使用方法,可以通过 get() 方法获取对象。当 JVM 认为内存空间不足时,就会去试图回收软引用指向的对象,也就是说在 JVM 抛出 OutOfMemoryError 之前,会去清理软引用对象。

软引用可以与引用队列(ReferenceQueue)联合使用,用来释放软引用自身。

1
2
3
4
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
SoftReference softObj = new SoftReference(obj, queue);
obj = null; // 去除强引用

当 softObj 软引用的 obj 被 GC 回收之后,softObj 对象就会被塞到 queue 中,之后我们可以通过这个队列的 poll() 来检查你关心的对象是否被回收了,如果队列为空,就返回一个null;否则就返回软引用对象,也就是 sofObj。

软引用一般用来实现内存敏感的缓存,如果有空闲内存就可以保留缓存,当内存不足时就清理掉,这样就保证使用缓存的同时不会耗尽内存。例如图片缓存框架中缓存图片就是通过软引用的。

举例: byte[] 数组缓存举例

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
29
30
31
32
33
34
35
36
/*-Xmx20m -XX:+PrintGCDetails -verbose:gc*/
public class Demo1_1 {
public static final int _4MB = 4 * 1024 * 1024;
public static void soft() {
// List --> SoftReference --> byte[]
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
public static void main(String[] args) {
// OutOfMemoryError: Java heap space
// List<byte[]> list = new ArrayList<>();
// for (int i = 0; i < 5; i++) {
// list.add(new byte[_4MB]);
// }
// 改为弱引用
soft();
}
}
// 第三次循环GC一次,第四次GC后仍然不够,再触发一次新的GC,回收软引用。
/**
循环结束5
null
null
null
null
[B@677327b6
**/

举例: 使用引用队列清理软引用

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
29
30
31
32
33
34
/*-Xmx20m -XX:+PrintGCDetails -verbose:gc*/
public class Demo1_1 {
public static final int _4MB = 4 * 1024 * 1024;
public static void soft() {
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for (int i = 0; i < 5; i++) {
// 关联了引用队列,当软引用所关联的byte[]被回收时,软引用自己会加入到queue中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 从队列中获取无用的软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("循环结束" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
public static void main(String[] args) {
soft();
}
}
/**
5
循环结束1
[B@677327b6
**/

弱引用

仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。

弱引用可以通过 WeakReference 类实现的,它的生命周期比软引用还要短,也是通过 get() 方法获取对象。

1
2
3
Object obj = new Object();
WeakReference<Object> weakObj = new WeakReference<Object>(obj);
obj = null; // 去除强引用

同样也可以配合 ReferenceQueue 使用,也同样适用于内存敏感的缓存。 ThreadLocal 中的 key 就用到了弱引用。

虚引用

也称为幽灵引用或者幻影引用。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

虚引用可以通过 PhantomReference 类实现,

1
2
3
4
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
PhantomReference<Object> phantomObj = new PhantomReference<Object>(obj, queue);
obj = null; // 去除强引用

无法通过虚引用访问对象的任何属性或者函数。虚引用仅仅只是提供了一种确保对象被 finalize 以后来做某些事情的机制。

虚引用必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存。

终结器引用(了解)

即使在可达性分析算法中不可达的对象,也并非时“非死不可”的。对于终结器引用对象,需要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法

    虚拟机将这两种情况都视为“没有必要执行”:

    • 当对象没有覆盖 finalized() 方法
    • finalize() 方法已经被虚拟机调用过
  2. 如果对象被判定为有必要执行 finalized() 方法,那么这个对象将会被放置在一个叫做 F-Queue 的队列之中,并在稍后由一个虚拟机自动创建、低优先级的 Finalizer 线程去执行它(不保证会等待它执行结束)。此时它还有机会在 finalized() 方法中去拯救自己(重新与引用链上的任何一个对象关联即可),如果它没有拯救自己,将会在第二次标记时被移除队列,面临被 GC 回收。

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。

垃圾回收算法

标记清除

“标记-清除”(Mark-Sweep)算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它的标记过程其实就是前面的可达性分析算法中判定垃圾对象的标记过程。

缺点:

  • 标记和清除两个过程的效率都不高。
  • 标记清除之后会产生大量不连续的内存碎片,内存碎片太多会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

复制

复制(Copying)收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

  • 每次只对一块内存进行回收,运行高效
  • 只需移动栈顶指针,按顺序分配内存即可,实现简单
  • 内存回收时不用考虑内存碎片的出现

缺点:可一次性分配的最大内存缩小了一半

复制收集算法比较适合新生代,在老年代中,对象存活率比较高,会执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如“标记-整理”算法。

标记整理

“标记-整理”(Mark-Compact)算法,标记过程和“标记-清除”算法一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

  • 没有内存碎片,但是效率比较慢

分代垃圾回收

“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。当前的商业虚拟机的垃圾收集都采用该算法。

在新生代中,每次垃圾回收时都会发现有大批对象死去,只有少量存活。选用复制算法,只需要付出少量存活对象的复制成本就可以完成垃圾回收。

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

新生代

主要用来存放新生的对象。一般占据堆的 1/3 空间,由于频繁创建对象,所以新生代会频繁地触发 Minor GC 进行垃圾回收。

尽管新生代采用“复制”算法,但是新生代中的对象绝大部分都是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间。而是将内存空间划分为伊甸园(Eden)幸存区From(SurvivorFrom)、**幸存区To(SurviviorTo)**三个空间,默认比例是 8:1:1

对象首先分配在伊甸园,当新生代空间不足时,触发 Minor GC,将伊甸园和幸存区From存活的对象使用“复制”算法复制到幸存区To中,然后让存活的对象年龄+1,并且交换幸存区From和幸存区To的指针。当某个对象年龄达到年龄阈值(默认值、最大值为15,4bit)时,就会把它们晋升到老年代中。在新生代中进行 GC 时,有可能遇到幸存区To空间没有足够空间存放新生代存活下来的对象,这些对象将通过分配担保机制(Handle Promotion,就像是去银行借贷)进入老年代。当老年代空间不足时,先尝试 Minor GC,如果还不足则触发 Full GC。

GC 会引发 Stop the world,停顿所有 Java 执行线程,确保可达性分析过程中引用(全局性引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表))关系的一致性。

一个线程内的 OOM 不会导致整个Java的线程结束。OOM 会清空线程占用的堆内存。

相关VM参数

含义 参数
堆初始大小 -Xms
堆最大大小 -Xmx 或者 -XX:MaxHeapSize=size
新生代大小 -Xmn 或(-XX:NewSize=size+XX:MaxNewSize=size
幸存区比例(动态) -XX:InitialSurvivorRadio=radio-XX:+UserAdaptiveSizePolicy
幸存区比例 -XX:SurvivorRadio=radio
晋升阈值 -XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
GC详情 -XX:+PrintGCDetails -verbose:gc
Full GC前MinorGC -XX:+ScavengeBeforeFullGC

理解GC日志

每一种收集器的日志形式都是由它们自身的实现所决定的但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性。

1
2
33.125: [GC [DefNew:3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]
100.667: [Full GC [Tenured:0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm:2999K->2999K(21248K)], 0.0150007 secs][Times:user=0.01 sys=0.00, real=0.02 secs]

最前面的数字“33.125:”和“100.667:”代表了 GC 发生的时间,这个数字的含义是从 Java 虚拟机启动以来经过的秒数。

GC 日志开头的 “[GC”和“[Full GC”说明了这次垃圾回收的停顿类型。如果有“Full”说明这次 GC 是发生了 Stop-The-World的。

“[DefNew”、“[Tenured”、“[Perm”表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集器是密切相关的。例如上面样例所使用的 Serial 收集器中新生代名为“Default New Generation”,所以显示的是“[DefNew",如果是 ParNew 收集器,新生代名称就会变为“[ParNew”。

后面方括号内部“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。

再往后,“0.0025925 secs”表示该内存区域 GC 所占用的时间,单位是秒。

垃圾回收器

7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。

image-20210407210645337

串行SerialGC

  • 单线程
  • 适合堆内存较小的客户端模式使用
  • 新生代内存不足发生的垃圾回收 - Minor GC
  • 老年代内存不足发生的垃圾回收 - Full GC
1
2
3
-XX:+UserSerialGC=Serial+SerialOld
# Serial “复制”算法
# SerialOld 工作在老年代,“标记-整理”算法

image.png

吞吐量优先ParallelGC

  • 多线程
  • 堆内存较大,多核CPU支持
  • 让单位时间内,STW时间最短
  • 新生代内存不足发生的垃圾回收 - Minor GC
  • 老年代内存不足发生的垃圾回收 - Full GC
1
2
3
4
5
6
7
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
# Parallel 新生代 “复制”算法
# ParallelOld 老年代 “标记-整理”算法
-XX:+UseAdaptiveSizePolicy # 采用自适应新生代大小调整策略
-XX:GCTimeRatio=ratio # 调整吞吐量目标(垃圾回收时间与总时间占比,1/(1+radio)),一般设置为19
-XX:MaxGCPauseMillis=ms # 最大暂停毫秒数,默认是200,与上一个参数是冲突的
-XX:ParallelGCThreads=n # 控制运行时线程数

imagee3d4c84df195ec48.png

响应时间优先 CMS

  • 多线程
  • 堆内存较大,多核CPU支持
  • 尽可能让STW单次时间最短
  • 新生代内存不足发生的垃圾回收 - Minor GC
  • 老年代内存不足发生的垃圾回收
    • 若 CMS 回收垃圾的速度能高于新产生垃圾的速度,那么是属于 并发回收 的阶段,不会产生 Full GC
    • 反之则会退化为 SerialOld回收器,产生 Full GC
1
2
3
4
5
6
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
# ParNew 新生代 “复制”算法
# CMS 老年代 “标记-清除”算法,但是当CMS失效时,会退化采用SerialOld来进行垃圾回收
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads # 一般设置为并行线程数量的1/4
-XX:CMSInitiatingOccupancyFraction=percent # 执行CMS垃圾回收的内存占比,预留空间给浮动垃圾(在垃圾回收过程中其他用户线程产生的新垃圾)
-XX:+CMSScavengeBeforeRemark # 在重新标记之前对新生代垃圾做一次回收工作,将来扫描对象就少,减轻回收压力

image9132a69296b13538.png

缺点:

  • CMS 回收器对 CPU 资源非常敏感。
  • CMS 回收器无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 失败而导致一次 Full GC 的产生。
    • 浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次回收中处理掉它们,只好留待下一次 GC 时再清理掉,这部分垃圾就被称为浮动垃圾。
  • CMS 使用“标记-清除”算法,会产生空间碎片问题。

imagee2a1c1115b2b2329.png

G1

Garbage First

  • 同时注重吞吐量和低延迟,默认暂停目标是200ms。
  • 超大堆内存,会将堆划分为多个大小相等的Region,新生代和老年代不再是物理隔离的,它们都是一部分 Region(不需要连续)的集合。
  • 整体上是“标记-整理”算法,两个区域之间是复制算法。
  • 新生代内存不足发生的垃圾回收 - Minor GC
  • 老年代内存不足发生的垃圾回收
    • 若 并行回收垃圾的速度能高于新产生垃圾的速度,那么是属于并行回收的阶段,不会产生 Full GC
    • 反之则会退化为 串行 回收器,产生 Full GC

相关JVM参数

1
2
3
-XX:+UseG1GC
-XX:G1HeapRegionSize=size
-XX:MacGCPauseMills=time

image71e588def6468222.png

G1 回收器会跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的回收时间,优先回收价值最大的 Region。这也是 Garbage-First 名称的又来。

  • 初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象
  • 并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象
  • 最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
  • 筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划

跨代引用时如何避免全堆扫描

在 G1 回收器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。

G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序对 Reference 类型的数据进行写操作时,会产生一个写屏障暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,就通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。

当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

CardTable 把 Region 分为一个一个的小表格,记录老年代中哪些区域存在跨代引用,存在的被称为脏卡区域。

内存分配与回收策略

对象优先在伊甸园区分配

大多数情况下,对象在新生代伊甸园区中分配。当伊甸园区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,避免在伊甸园区及两个幸存区之间发生大量的内存复制。

PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款回收器有效。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在伊甸园区出生,并经过第一次 Minor GC 后仍然存活,并且能被幸存区容纳的话,将被移动到另一块幸存区空间中,并且对象年龄设为1。

对象在幸存区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就
将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold设置,最大为 15。

动态对象年龄判定

如果在幸存区空间中相同年龄所有对象大小的总和大于幸存区空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

冒险:新生代使用复制收集算法,但为了内存利用率,只使用其中一个幸存区空间来作为轮换备份。当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把幸存区无法容纳的对象直接进入老年代。

垃圾回收调优

显示所有VM相关参数

1
java -XX:PrintFlagsFinal -version | findstr "GC"

调优跟应用、环境有关,没有放之四海而皆准的法则。

调优领域

  • 内存
  • 锁竞争
  • CPU 占用
  • IO

确定目标

根据【低延迟】还是【高吞吐量】,选择合适的回收器

  • 低延迟:CMS,G1,ZGC
  • 高吞吐量:ParallelGC

最快的GC是不发生GC

查看 Full GC 前后的内存占用,考虑以下几个问题:

  • 数据是不是太多?
    • resultSet = statement.executeQuery(“select * from 大表”),应该添加 limit 限制,即进行分页查询操作,限制查询出来数据的大小,防止表中数据爆炸。
  • 数据表示是否太臃肿?
    • 对象图(对象相关信息全都查出来了),应该要什么查什么,避免对堆内存造成不必要的浪费。
    • 对象大小
  • 是否存在内存泄漏?
    • static Map map = 静态的map对象不断地向它添加对象,会造成OOM
    • 解决方法:软引用、弱引用、第三方缓存实现比如redis等

新生代调优

1)新生代的特点

  • 所有的 new 操作的内存分配非常廉价
    • TLAB thread-local allocation buffer,每个线程都会在伊甸园中分配一块私有的区域,即TLAB。如果启动了本地线程分配缓存,当 new 一个对象的时候,会优先检查该区域内有没有足够的空间,如果有则在这块区域中给对象分配内存。
  • 死亡对象的回收代价是零
  • 大部分对象用过即死
  • Minor GC 的时间远远低于 Full GC

2)新生代理想大小

并不是越大越好,太大会导致原本用完即死的对象一直停留在内存中,等到新生代内存不足触发 GC 时才会被回收。

  • 新生代能容纳所有【并发量 *(请求响应)】的数据
  • 幸存区大到能保留【当前活跃对象 + 需要晋升对象】
  • 晋升阈值配置得当,让长时间存活的对象尽快晋升
    • -XX:MaxTenuringThreshold=threshold,调整最大晋升阈值
    • -XX:+PrintTenuringDistribution,打印晋升详细信息

老年代调优

以 CMS 为例

  • CMS 的老年代内存越大越好
  • 先尝试不做调优,如果没有 Full GG,那么说明老年代空间很充裕,否则先尝试调优新生代
  • 观察发生 Full GC 时老年代内存占用,将老年代内存预设调大1/4~1/3
    • -XX:CMSInitiatingOccupanyFraction=percent,控制老年代在空间占用多少的时候进行垃圾回收。

案例

  • Full GC 和 Minor GC 频繁

    GC 频繁说明空间紧张。如果是新生代紧张,被塞满,幸存区空间紧张,导致空间晋升阈值降低,老年代存了很多生存周期短的对象,进而触发了 Full GC。先试着增大新生代内存,增大幸存区空间和晋升阈值。

  • 请求高峰期发生 Full GC,单次暂停时间特别长(CMS)

    因为业务需求需要低延迟,所以选择了 CMS。查看 GC 日志 CMS 的哪个阶段耗时较长。比较慢的一般在重新标记阶段,耗时比较长,因为 CMS 要扫描整个堆内存。设置在重新标记前先做一次新生代回收。

    1
    -XX:+CMSScavengeBeforeRemark
  • 老年代充裕情况下,发生 Full GC(CMS 假设使用 jdk 1.7)

    1.7 及以前使用永久代作为方法区,1.8 使用元空间。永久代空间不足也会导致 Full GC,所以扩大永久代空间。(1.8 后元空间的垃圾回收不由 Java 控制?)

虚拟机性能监控与故障处理工具

给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC 日志、线程快照(threaddump/javacore 文件)、堆转储快照(heapdump/hprof 文件)等。经常使用适当的虚拟机监控和分析的工具可以加快我们分析数据、定位解决问题的速度,但在学习工具前,也应
当意识到工具永远都是知识技能的一层包装,没有什么工具是“秘密武器”,不可能学会了就能包治百病。

名称 主要作用
jps JVM Process Status Tool,显示指定系统内所有的 HotSpot 虚拟机进程
jstat JVM Statistics Monitoring Tool,用于收集 HotSpot 虚拟机各方面的运行数据
jinfo Configuration Info for Java,显示虚拟机配置信息
jmap Memory Map for Java,生成虚拟机的内存转储快照(heapdump 文件)
jhat JVM Heap Dump Browser,用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果
jstack Stack Trace for Java,显示虚拟机的线程快照

类加载与字节码技术

image.png

类文件结构

根据 JVM 规范的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种数据只有两种数据类型:无符号数和表。

无符号数属于基本的数据类型,以 u1u2u4u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构造字符串值。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型。所有表都习惯性地以 _info 结尾。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic;// 魔数
u2 minor_version;// 次版本号
u2 major_version;// 主版本号
u2 constant_pool_count;// 常量池容量计数值
cp_info constant_pool[constant_pool_count-1];// 常量池
u2 access_flags;// 访问标志
u2 this_class;// 类索引
u2 super_class;// 父类索引
u2 interfaces_count;// 接口索引计数值
u2 interfaces[interfaces_count];// 接口索引集合
u2 fields_count;// 字段表计数值
field_info fields[fields_count];// 字段表集合
u2 methods_count;// 方法表计数值
method_info methods[methods_count];// 方法表集合
u2 attributes_count;// 属性表计数值
attribute_info attributes[attributes_count];// 属性表集合
}

我们以下面的 Java 程序为例来分析 Class 文件的结构

1
2
3
4
5
6
7
package top.zbsong.clazz;
public class TestClass {
private int m;
public int inc() {
return m + 1;
}
}

将上面的代码使用 JDK 8 以上编译输出 Class 文件为基础来进行分析。

命令:javac .\TestClass.java

得到文件 TestClass.class,在 sublime 中打开内容如下:(注意,正常情况下偏移地址应该是8位,这里只标记出来4位是为了页面能够容下信息)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
offset  0 1  2 3  4 5  6 7  8 9  A B  C D  E F
0000 cafe babe 0000 0038 0013 0a00 0400 0f09
0010 0003 0010 0700 1107 0012 0100 016d 0100
0020 0149 0100 063c 696e 6974 3e01 0003 2829
0030 5601 0004 436f 6465 0100 0f4c 696e 654e
0040 756d 6265 7254 6162 6c65 0100 0369 6e63
0050 0100 0328 2949 0100 0a53 6f75 7263 6546
0060 696c 6501 000e 5465 7374 436c 6173 732e
0070 6a61 7661 0c00 0700 080c 0005 0006 0100
0080 1a74 6f70 2f7a 6273 6f6e 672f 636c 617a
0090 7a2f 5465 7374 436c 6173 7301 0010 6a61
00A0 7661 2f6c 616e 672f 4f62 6a65 6374 0021
00B0 0003 0004 0000 0001 0002 0005 0006 0000
00C0 0002 0001 0007 0008 0001 0009 0000 001d
00D0 0001 0001 0000 0005 2ab7 0001 b100 0000
00E0 0100 0a00 0000 0600 0100 0000 0200 0100
00F0 0b00 0c00 0100 0900 0000 1f00 0200 0100
0100 0000 072a b400 0204 60ac 0000 0001 000a
0110 0000 0006 0001 0000 0005 0001 000d 0000
0120 0002 000e

1. 魔数magic

每个 Class 文件的头 4 个字节称为魔数(Magic NUmber),它的唯一作用是确定这个文件是否为一个能够被虚拟机接受的 Class 文件。

Java 文件的魔数的值为:0xCAFEBABE,观察 TestClass.class 文件中的头 4 个字节

cafe babe 0000 0038 0013 0a00 0400 0f09

能够印证我们的观点。

2. Class文件的版本

紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节为次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。

观察 TestClass.class 文件的第 5 ~ 8 个字节

cafe babe 0000 0038 0013 0a00 0400 0f09

我电脑安装的是 JDK 12,所以主版本号是 0x0038(十六进制,转换十进制为 56),如果是 JDK 8 的话,主版本号应该是 0x0034(十六进制,转换十进制为 52)。

具体对应关系可以上网搜索。

3. 常量池

紧接着主次版本号之后的是常量池入口。常量池可以理解为 Class 文件之中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一,同时它还是在 Class 文件中第一个出现的表类型数据项目。

常量池包含两个部分:

  1. 常量池容量计数值(constant_pool_count),u2 类型
  2. 常量池(constant_pool),cp_info 表类型

常量池容量计数从 1 开始计数, 把第 0 项常量空出来的目的是:满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义。

观察 TestClass.class 文件的常量池容量(偏移地址:0x0000 0008)

cafe babe 0000 0038 0013 0a00 0400 0f09

0x0013(十六进制,转换十进制为 19)这代表常量池中有 19 项常量,索引值范围为 1~19。

常量池中主要存放两大类常量:

  • 字面量(Literal)

    接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。

  • 符号引用(Symbolic References)

    属于编译原理方面的概念,包括下面三类常量:

    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符

常量池中的每一项常量都是一个表,表都有一个共同的特点,就是表开始的第一位是一个 u1 类型的标志位(tag,取值见表 6-3 中标志列),代表当前这个常量属于哪种常量类型。下面列举一些常量类型所代表的的具体含义,且这些常量类型各自均有自己的结构。

image9f9cfba27ab6f4b8.png

第一项常量

回头观察 TestClass.class 文件常量池中的第一项常量(偏移地址是 0x0000 000A)为 0x0a

cafe babe 0000 0038 0013 0a00 0400 0f09

查表 6-3 发现这个常量属于 CONSTANT_Methodref_info 类型,此类型的常量代表类中方法的符号引用,于是我上互联网进行搜索这个类型的结构

类型 标志 数量 描述
u1 tag 1 值为10
u2 class_index 1 指向声明方法的类描述符 CONSTANT_Class_info 的索引项
u2 name_and_type_index 1 指向名称及类型描述符 CONSTANT_NameAndType_info 的索引项

tag 是标志位,代表当前这个常量属于哪种常量类型。

class_index 是一个索引值,常量池在该索引处的项必须是 CONSTANT_Class_info 结构,表示一个类或接口,当前字段或方法是这个类或接口的成员。此处必须是类。

name_and_type_index 是一个索引值,常量池在该索引处的项必须是 CONSTANT_NameAndType_info 结构,它表示当前字段或方法的名字和描述符。此处必须是方法描述符。

观察 TestClass.class 文件,该常量的所有数据项为:

cafe babe 0000 0038 0013 0a00 0400 0f09

tag(偏移地址 0x0000 000A)为 0x0a 代表个常量属于 CONSTANT_Methodref_info 类型,class_index(偏移地址 0x0000 000B)为 0x0004,即指向了常量池中的第 4 项常量,name_and_type_index(偏移地址 0x0000 000D)为 0x000f,即指向了常量池中的第 15 项常量。

篇幅受限,我们就不挨个去分析第 4 项常量和第 15 项常量,直接通过命令 javap -verbose .\TestClass.class 输出常量表。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
javap -verbose  .\TestClass.class                                                                               Classfile /D:/TestClass.class
Last modified 2021年4月10日; size 292 bytes
MD5 checksum a4ca6bd139db017b6f2861fa6decc304
Compiled from "TestClass.java"
public class top.zbsong.clazz.TestClass
minor version: 0
major version: 56
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #3 // top/zbsong/clazz/TestClass
super_class: #4 // java/lang/Object
interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // top/zbsong/clazz/TestClass.m:I
#3 = Class #17 // top/zbsong/clazz/TestClass
#4 = Class #18 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 TestClass.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 top/zbsong/clazz/TestClass
#18 = Utf8 java/lang/Object
{
public top.zbsong.clazz.TestClass();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0

public int inc();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 5: 0
}
SourceFile: "TestClass.java"

第4项常量

可以看到第 4 项常量是一项 CONSTANT_Class_info 类型,其结构关系如下,

类型 标志 数量 描述
u1 tag 1 值为7
u2 name_index 1 指向全限定名常量项的引用

name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量代表了类(或者接口)的全限
定名,通过常量表可以知道它的 name_index 指向第 18 个常量,该常量确实是一个 CONSTANT_Utf8_info 类型常量,。

CONSTANT_Utf8_info 类型常量的结构如下:

类型 标志 数量 描述
u1 tag 1 值为1
u2 length 1 UTF-8 编码的字符串长度是多少字节
u1 bytes length 长度为 length 的 UTF-8 编码的字符串

顺带一提,由于 Class 文件中方法、字段等都需要引用 CONSTANT_Utf8_info 型常量来描述名称,所以 CONSTANT_Utf8_info 型常量的最大长度也就是 Java 中方法、字段名的最大长度。而这里的最大长度就是 length 的最大值,即 u2 类型能表达的最大值 65535。所以 Java 程序中如果定义了超过 64KB 英文字符的变量或方法名,将会无法编译。

于是我们就解析出来第 4 项常量所代表的含义是这个成员方法的所属类是 java/lang/Object

第15项常量

第 15 项常量是一项 CONSTANT_NameAndType_info 类型的项目,其结构关系如下:

类型 标志 数量 描述
u1 tag 1 值为12
u2 index 1 指向该字段或方法名称常量项的引用
u2 index 1 指向该字段或方法描述符常量项的引用

通过变量表我们知道第 15 项常量又指向第 7 项和第 8 项常量,分别表示方法的名称以及方法的描述符 "<init>":()V。该内容具体的含义后边再进行分析。

于是我们就可以知道,第 1 项常量存放了一个方法,该方法属于 java/lang/Object 类,该方法的名称以及描述符是 "<init>":()V

后面的常量就不一一赘述了,有兴趣可以自己挨个分析。

4. 访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等。具体的标志位以及标志的含义见表 6-7。

image26377ac48f855564.png

access_flags 中一共有 16 个标志位可以使用,当前只定义了其中 8 个,没有使用到的标志位要求一律为 0。

由于 TestClass 是一个普通 Java 类,不是接口、枚举或者注解,被 public 关键字修饰但没有被声明为 final 和 abstract。因此它只有 ACC_PUBLIC、ACC_SUPER 标志应当为真,所以它的 access_flags 的值应该为 0x0001 | 0x0020 = 0x0021。观察 TestClass.class 文件,access_flags 标志(偏移地址 0x0000 00AE)的确为 0x0021

7661 2f6c 616e 672f 4f62 6a65 6374 0021

5. 类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定这个类的继承关系。

类索引用 于确定这个类的全限定名。

父类索引 用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后。

类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info 类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

1
2
3
4
5
6
# 类索引查找全限定名的过程
this_class:{value:3}
-->
#3 CONSTANT_Class_info:{index:17}
-->
#17 CONSTANT_Utf8_info:{length:26,bytes:top/zbsong/clazz/TestClass}

接口索引集合 用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中。

从偏移地址 0x0000 00B0 开始的 3 个 u2 类型的值分别为 0x0003、0x0004、0x0000,也就是类索引为 3,父类索引为 4,接口索引集合大小为 0,查询前面代码中 javap 命令计算出来的常量池,找出对应的类和父类的常量。

1
2
3
4
#3 = Class              #17            // top/zbsong/clazz/TestClass
#4 = Class #18 // java/lang/Object
#17 = Utf8 top/zbsong/clazz/TestClass
#18 = Utf8 java/lang/Object

6. 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。

描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 L 加对象的全限定名来表示。

对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组,将被记录为:“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录为“[I”。

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法 void inc() 的描述符为“()V”,方法 java.lang.String toString() 的描述符为“()Ljava/lang/String;”,方法 int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex) 的描述符为“([CII[CIII)I”。

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

7. 方法表集合

Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。

方法里的 Java 代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面,

与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”方法。

8. 属性表集合

属性表(attribute_info),在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

对于每个属性,它的名称需要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个 u4 的长度属性去说明属性值所占用的位数即可。

属性表结构

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

Code属性

Java 程序方法体中的代码经过 Javac 编译器处理后,最终变为字节码指令存储在 Code 属性内。

用 javap 命令计算字节码指令

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
javap -verbose  .\TestClass.class  
...
{
public top.zbsong.clazz.TestClass();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0

public int inc();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 5: 0
}
SourceFile: "TestClass.java"

看描述符 ()V 虽然方法是无参的,但是 args_size=1,这是因为在任何实例方法里面,都可以通过 this关键字访问到此方法所属的对象。。这个访问机制对 Java 程序的编写很重要,而它的实现却非常简单,仅仅是通过 Javac 编译器编译的时候把对 this 关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个 Slot 位来存放对象实例的引用,方法参数值从 1 开始计算。

但是把方法声明为 static,那 args_size 的值就会等于 0 而不是 1了。

字节码指令

Java 虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型所代表的特殊字符替换指令模板中的T,就可以得到一个具体的字节码指令。

i 代表对 int 类型的数据操作,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。

大部分的指令都没有支持整数类型 byte、char、short 甚至是 boolean。大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型。

加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。

将一个局部变量加载到操作数栈:iload、iload_<n>、lload、lload_<n>、fload、fload_ <n>、dload、dload_<n>、aload、aload_<n>
将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、 fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
将一个常数加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、 iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
扩充局部变量表的访问索引的指令:wide

运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

加法指令:iadd、ladd、fadd、dadd
减法指令:isub、lsub、fsub、dsub
乘法指令:imul、lmul、fmul、dmul
除法指令:idiv、ldiv、fdiv、ddiv
求余指令:irem、lrem、frem、drem
取反指令:ineg、lneg、fneg、dneg
位移指令:ishl、ishr、iushr、lshl、lshr、lushr
按位或指令:ior、lor
按位与指令:iand、land
按位异或指令:ixor、lxor
局部变量自增指令:iinc
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

类型转换指令

Java 虚拟机直接支持(即转换时无需显式的转换指令)以下数值类型的宽化类型转换:

  • int 类型到 long、float 或者 double 类型
  • long 类型到 float、double 类型
  • float 类型到 double类型

处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换 指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f

对象创建与访问指令

创建类实例的指令:new
创建数组的指令:newarray、anewarray、multianewarray
访问类字段(static 字段,或者称为类变量)和实例字段(非static 字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
取数组长度的指令:arraylength
检查类实例类型的指令:instanceof、checkcast

操作数栈管理指令

将操作数栈的栈顶一个或两个元素出栈:pop、pop2
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
将栈最顶端的两个数值互换:swap

控制转移指令

条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
复合条件分支:tableswitch、lookupswitch
无条件分支:goto、goto_w、jsr、jsr_w、ret

方法调用和返回指令

invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic 指令用于调用类方法(static方法)。
invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturnareturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。

异常处理指令

在 Java 程序中显式抛出异常的操作(throw语句)都由 athrow 指令来实现,除了用 throw 语句显式抛出异常情况之外,Java 虚拟机规范还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。

处理异常(catch语句)不是由字节码指令来实现的,而是采用异常表来完成的。

同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

同步一段指令集序列通常是由 Java 语言中的 synchronized 语句块来表示的,Java 虚拟机的指令集中有 monitorentermonitorexit两条指令来支持 synchronized 关键字的语义。

为了保证在方法异常完成时 monitorentermonitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

编译期处理 语法糖

语法糖,其实就是指 java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担。

这里用 Java 代码来解释语法糖。

默认构造器

1
2
public class Candy1 {   
}

编译成 class 后的代码:

1
2
3
4
5
6
public class Candy1 {
// 无参构造是编译器帮助我们加上的
public Candy1() {
super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
}

自动拆装箱

这个特性是 JDK 5 开始加入的,代码片段 1:

1
2
3
4
5
6
public class Candy2 {
public static void main(String[] args) {
Integer x = 1;
int y = x;
}
}

这段代码在 JDK 5 之前是无法编译通过的,必须改写为代码片段 2:

1
2
3
4
5
6
public class Candy2 {
public static void main(String[] args) {
Integer x = IInteger.valueOf(1); // 装箱
int y = x.intValue(); // 拆箱
}
}

显然之前版本的代码太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情在 JDK 5 以后都是由编译器在编译阶段完成。即代码片段 1 都会在编译阶段被转换为代码片段 2。

泛型集合取值

泛型也是在 JDK 5 开始加入的特性,但 Java 在编译泛型代码后悔执行 泛型擦除 的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当作了 Object 类型来处理:

1
2
3
4
5
6
7
public class Candy3 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add(Object e)
Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
}
}

所以在取值时,编译器真正生成的字节码中,还要额外做一个类型转换的操作:

1
2
// 需要将 Object 转为 Integer
Integer x = (Integer)list.get(0);

如果前面的 x 变量类型修改为 int 基本类型,那么最终生成的字节码是:

1
2
// 需要将 Object 转换为 Integer,并执行拆箱操作
int x = ((Integer)list.get(0).intValue());

还好这些麻烦事都不用自己做。

泛型擦除 即在字节码层面,调用 List.add 或者 List.get 方法时,会擦除泛型信息,统一按照 Object 来处理。

擦除的是字节码上的泛型信息,但是 LocalVariabletypeTable 局部变量类型泛型表仍然保留了方法参数泛型的信息。

可变参数

可变参数也是 JDK 5 开始加入的新特性:

1
2
3
4
5
6
7
8
9
public class Candy4 {
public static void foo(String... args) {
String[] array = args; // 直接赋值
System.out.println(array);
}
public static vois main(String[] args) {
foo("hello", "world");
}
}

可变参数 String... arg 其实是一个 String[] args,从代码中的复制语句就可以看出来。

同样 Java 编译器会在编译期间将上述代码变换为:

1
2
3
4
5
6
7
8
9
public class Candy4 {
public static void foo(String[] args) {
String[] array = args; // 直接赋值
System.out.println(array);
}
public static vois main(String[] args) {
foo("hello", "world");
}
}

注意

如果调用了 foo() 则等价代码为 foo(new String[]{}),创建了一个空的数组,而不会传递 null 进去。

foreach循环

仍是 JDK 5 开始引入的语法糖,数组的循环:

1
2
3
4
5
6
7
8
public class Candy5_1 {
public static void main(String[] args) {
int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖
for (int e : array) {
System.out.println(e);
}
}
}

会被编译器转换为:

1
2
3
4
5
6
7
8
9
10
11
public class Candy5_1 {
public Candy5_1(){
}
public static void main(String[] args){
int[] array = new int[]{1, 2, 3, 4, 5};
for (int i=0; i < array.length; ++i){
int e = array[i];
System.out.println(e);
}
}
}

集合的循环,

1
2
3
4
5
6
7
8
public class Candy5_2 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer i : list) {
System.out.println(i);
}
}
}

实际被编译器转化为对迭代器的引用:

1
2
3
4
5
6
7
8
9
10
11
12
public class Candy5_2 {
public Candy5_2(){
}
public static void main(String[] args){
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iter = list.iterator();
while (iter.hasNext()) {
Integer e = (Integer)iter.next();
System.out.println(e);
}
}
}

switch字符串

从 JDK 7 开始,switch 可以作用于字符串和枚举类,但变量不能为null。这个功能其实也是语法糖,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Candy6_1 {
public static void choose(String str) {
switch (str) {
case "hello": {
System.out.println("h");
break;
}
case "world": {
System.out.println("w");
break;
}
}
}
}

会被编译器转换为:

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
public class Candy6_1 {
public Candy6_1() {
}
public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 99162322: // hello 的 hashCode,提高比较效率,减少比较次数
if (str.equals("hello")) { // equals防止hashcode冲突
x = 0;
}
break;
case 113318802: // world 的 hashCode
if (str.equals("world")) {
x = 1;
}
break;
}
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}

当 hashCode 冲突的情况,会在该 hashCode 下用 if...else 语句来判断处理。

switch枚举

switch 枚举的例子,原始代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Sec {
MALE, FEMALE
}
public class Candy7 {
public static void foo(Sex sex) {
switch (sex) {
case MALE:
System.out.println("男"); break;
case FEMALE:
System.out.println("女"); break;
}
}
}

转换后代码:

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
public class Candy7 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的ordinal()=0,FEMALE 的ordinal()=1
*/
static class $MAP {
// 数组大小即为枚举元素个数,里面存储 case 用来对比的数字
static int[] map = new int[2];
static {
map[Sex.MALE.ordinal()] = 1;
map[Sex.FEMALE.ordinal()] = 2;
}
}
public static void foo(Sex sex) {
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("男"); break;
case 2:
System.out.println("女"); break;
}
}
}

枚举类

JDK 7 新增了枚举类,以前面的性别枚举为例:

1
2
3
enum Sex {
MALE, FEMALE
}

转换后代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public final class Sex extends Enum<Sex>{ // 枚举类不能再被继承
public static final Sex MALE;
public static final Sex FEMALE;
public static final Sex[] $VALUES;
static {
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}
private Sex(String name, int ordinal){
super(name, ordinal);
}
public static Sex[] values(){
return $VALUES.clone();
}
public static Sex valueOf(String name){
return Enum.valueOf(Sex.class, name);
}
}

try-with-resources

JDK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources

1
2
3
try(资源变量 = 创建资源对象) {
} catch() {
}

其中资源对象需要实现 AutoCloseable 接口,例如 InputStream、OutputStream、Connection、Statement、ResultSet 等接口都实现了 AutoCloseable,使用 try-with-resources 可以不用写 finally 语句块,编译器会帮助生成关闭资源代码,例如:

1
2
3
4
5
6
7
8
9
public class Candy9 {
public static void main(String[] args) {
try (InputStream is = new FileInputStream("d:\\1.txt")) {
System.out.println(is);
} catch (IOException e) {
e.printStackTrace();
}
}
}

会被转换为:

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
29
30
31
32
33
34
35
public class Candy9{
public Candy9(){
}
public static void main(String[] args){
try{
InputStream is = new FileInputStream("d:\\q.txt");
Throwable t = null;
try{
System.out.println(is);
} catch (Throwable e1){
// t 是代码出现的异常
t = e1;
throw e1;
} finally {
// 判断了资源不为空
if (is != null){
// 如果我们代码有异常
if (t != null){
try{
is.close();
} catch (Throwable e2){
// 如果close有异常,作为被压制异常添加,防止try-with-resourcesfinally里的异常信息的丢失
t.addSuppressed(e2);
}
} else {
// 如果我们代码没有异常,close出现的异常就是最后catch块中的e
is.close();
}
}
}
} catch (IOException e){
e.printStackTrace();
}
}
}

方法重写时的桥接方法

方法重写时对返回值分两种情况:

  • 父子类的返回值完全一致
  • 子类返回值可以是父类返回值的子类
1
2
3
4
5
6
7
8
9
10
11
12
class A {
public Number m() {
return 1;
}
}
class B extends A {
@Override
// 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类
public Integer m() {
return 2;
}
}

对于子类,Java 编译器会做如下处理:

1
2
3
4
5
6
7
8
9
10
class B extends A{
public Integer m(){
return 2;
}
// 此方法才是真正重写了父类public Number m()的方法
public synthetic bridge Number m(){
// 调用public Integer m()
return m();
}
}

其中桥接方法比较特殊,仅对Java虚拟机可见,并且与原来的public Integer m()没有命名冲突,可以用下面的反射代码来验证:

1
2
3
for (Method m : B.class.getDeclaredMethods()){
System.out.println(m);
}

匿名内部类

匿名内部类

源代码:

1
2
3
4
5
6
7
8
9
10
public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok");
}
};
}
}

转换后代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 额外生成的类
final class Candy10$1 implements Runnable {
Cady10$1() {
}
public void run() {
System.out.println("ok");
}
}

public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Candy10$1();
}
}

引用局部变量的匿名内部类

1
2
3
4
5
6
7
8
9
10
public class Candy11{
public static void test(final int x){
Runnable runnable = new Runnable(){
@Override
public void run(){
System.out.println("OK" + x);
}
};
}
}

转换后代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 额外生成类
final class Candy11$1 implements Runnable{
int val$x;
Candy$1(int x){
this.val$x = x;
}
public void run(){
System.out.println("OK" + this.val$x);
}
}

public class Candy10 {
public static void test(final int x) {
Runnable runnable = new Candy11$1(x);
}
}

这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:因为在创建 Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val$x 属性,所以 x 不应该再发生变化了,如果变化,那么 val$x 属性没有机会再跟着一起变化。

类加载机制

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

  • 在实际情况中,每个 Class 文件都有可能代表着 Java 语言中的一个类或接口,后文中直接对“类”的描述都包括了类和接口的可能性,而对于类和接口需要分开描述的场景会特别指明
  • 与前面介绍 Class 文件格式时的约定一致,笔者本章所提到的“Class 文件”并非特指某个存在于具体磁盘中的文件,这里所说的“Class 文件”应当是一串二进制的字节流,无论以何种形式存在都可以

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载 7 个阶段。

image.png

红框标记的加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。

什么时候开始类加载阶段(可以看初始化阶段的开始时机):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

加载

在加载阶段主要完成三件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序

链接

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

它大致完成了 4 个检验动作:

  1. 文件格式验证:第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
  2. 元数据验证:第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
  3. 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:第四个阶段可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

准备

准备阶段是为被 static 修饰的变量分配内存,设置默认值。

  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段(构造方法)完成。
  • 如果 static 变量是 final 的基本类型,那么编译阶段值就确定了,赋值在准备阶段完成。
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用:以一组符号来描述所引用的目标。

直接引用:直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Load{
public static void main(String[] args) throws ClassNotFoundException, IOException{
ClassLoader classloader = Load.class.getClassLoader();
// loadClass方法不会导致类的解析和初始化
Class<?> c = classloader.loadClass("类全限定类名.C");
// new C();会
new C();
System.in.read();
}
}
class C{
D d = new D();
}
class D{
}

初始化

<cinit>()V 方法,初始化即调用 <cinit>()V,虚拟机会保证这个类的构造方法的线程安全。

发生的时机

概括的说,类的初始化是懒惰的。

  • main 方法所在的类,总会被首先初始化。
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

不会导致类初始化的情况

  • 访问类的 static final 的静态常量(基本类型和字符串)不会触发初始化,在链接时候就完成了
  • 访问类对象 .class 不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass 方法
  • Class.forName 的参数 2 为 false 时
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
29
30
31
32
33
34
35
36
37
38
39
40
public class Load {
// main 方法所在的类,总会被首先初始化。
static {
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException {
// 1. final静态变量不会初始化
System.out.println(B.b);
// 2. 类对象.class不会触发初始化
System.out.println(B.class);
// 3. 创建该类的数组不会触发初始化
System.out.println(new B[0]);
// 4. 不会初始化类B,但是会加载B、A
ClassLoader c1 = Thread.currentThread().getContextClassLoader();
c1.loadClass("java.Load.B");
// 5. 不会初始化类B,但会加载B、A
ClassLoader c2 = Thread.currentThread().getContextClassLoader();
Class.forName("java.Load.B", false, c2);
// 1. 首次访问这个类的静态变量或静态方法时
System.out.println(A.a);
// 2. 子类初始化,如果父类还没初始化会引发
System.out.println(B.c);
// 3. 子类访问父类静态变量,只触发父类初始化
System.out.println(B.a);
// 4. 会初始化类B,并先初始化类A
Class.forName("java.Load.B");
}
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
static final double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}

练习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Load{
public static void main(String[] args){
System.out.println(E.a); // 不会初始化,访问类的static final的静态常量(基本类型和字符串)不会触发初始化,在链接时候就完成了
System.out.println(E.b); // 不会初始化
System.out.println(E.c); // 初始化
}
}
class E{
public static final int a = 10;
public static final String b = "hello";
public static final Integer c = 20; // Integer.valueOf(20)
static{
System.out.println("init E");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 懒惰实例化,懒惰单例模式
// 初始化时的线程安全是有保障的
public class Load {
public static void main(String[] args) {
Singleton.test(); // test,未导致初始化
Singleton.getInstance(); //lazy holder init,导致初始化
}
}
class Singleton {
private Singleton() {}
public static void test() {
System.out.println("test");
}
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
static {
System.out.println("lazy hoder init");
}
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}

类加载器

以 JDK 8 为例:

名称 加载哪儿的类 说明
Bootstrap ClassLoader JAVA_HOME/jre/lib 无法直接访问
Extension ClassLoader JAVA_HOME/jre/lib/ext 上级为 Bootstrap,显示为 null
Application ClassLoader classpath 上级为 Extension
自定义类加载器 自定义 上级为 Application

双亲委派模式:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

启动类加载器

用 Bootstrap 类加载器加载类:

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
package cn.itcast.jvm.t3.load;
public class F {
static {
System.out.println("bootstrap F init");
}
}

public class Load5_1 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
System.out.println(aClass.getClassLoader());
}
}
// 控制台编译
/*
java -Xbootclasspath/a:. cn.itcast.jvm.t3.load.Load5_1
# /a:.表示将当前目录追加到bootclasspath后
# 使用该方法替代核心类
# java -Xbootclasspath:<new bootclasspath>
# java -Xbootclasspath/a:<追加路径> 后追加
# java -Xbootclasspath/p:<追加路径> 前追加
*/
// 输出
/*
bootstrap F init
null
*/

如果是应用程序加载器是 AppClassLoader,如果是扩展类加载器会打印 ExtClassLoader,但是启动类加载器是 C++ 代码编写的,不能通过 Java 代码直接访问,所以打印的是 null。

扩展类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package cn.itcast.jvm.t3.load;
public class G {
static {
System.out.println("classpath G init");
}
}

public class Load5_2 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.G");
System.out.println(aClass.getClassLoader());
}
}

// 输出
/*
classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2
*/

写一个同名类

1
2
3
4
5
public class G {
static {
System.out.println("ext G init");
}
}

打个 jar 包

1
jar -cvf my.jar com/itcast/jvm/t3/G.class

拷贝到 JAVA_HOME/jre/lib/ext,重新执行 Load5_2

输出

1
2
ext G init
sun.misc.Launcher$ExtClassLoader@29453f44

双亲委派模式

即调用类加载器的 loadClass 方法时,查找类的规则。虽然是双亲,但是它们并没有继承关系。

双亲委派模式: 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

实现逻辑: 先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。

好处: 可以避免类的重复加载(相同的类文件被不同的类加载器加载产生的是两个不同的类)

Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 JAVA_HOME/jre/lib 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

线程上下文类加载器(破坏双亲委派模式)

一句话总结:线程上下文类加载器在默认情况下是指向的应用程序加载器。在使用 SPI 服务去加载比如 JDBC 驱动时,在启动类加载器中通过当前线程去获取线程上下文类加载器,然后利用线程上下文类加载器去加载它不能完成的类加载任务。这个时候就破坏了双亲委派模式的一般性原则。

我们在使用 JDBC 时,都需要加载 Driver 驱动,但是我们几乎没写过第一句,

1
2
3
Class.forName("com.mysql.jdbc.Driver");
con=DriverManager.getConnection("jdbc:mysql://localhost:3306/tsetjdbc",
"root", "123456");

也能够让 com.mysql.jdbc.Driver 正确加载,这是为什么呢?

这归功于线程上下文类加载器。 在 DriverManager.getConnection() 中,调用类的静态方法会初始化该类,进而执行该类的静态代码块,DriverManager 的静态代码块:

1
2
3
4
5
6
7
8
9
public class DriverManager {
// 注册驱动的集合
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
// 初始化驱动
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
}

打印 DriverManager 的类加载器:

1
System.out.println(DriverManager.class.getClassLoader());	// null

表示它的类加载器是 Bootstrap ClassLoader,回到 JAVA_HOME/jre/lib 下搜索类,但是该目录下显然并没有 mysql 的 jar 包,继续查看 loadInitialDriver() 方法:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
private static void loadInitialDrivers() {
String drivers;
try {
// 1 先读取系统属性
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// 2 通过SPI加载驱动类
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
// 3 使用jdbc.drivers定义的驱动名加载驱动
if (drivers == null || drivers.equals("")) {
return;
}

String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
// 使用AppClassloader加载
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}

先看 3 ,说明最后使用的是 Class.forName 完成类的加载和初始化,关联的是应用程序类加载器,因此可以顺利完成类加载。

1 就是 Service Provider Interface(SPI),约定如下,在 jar 包中的 META-INF/services 包下,以接口全限定名为文件,文件内容是实现类名称。

这样就可以使用:

1
2
3
4
5
ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class);
Iterator<接口类型> iter = allImpls.iterator();
while (iter.hasNext()){
iter.next();
}

来得到实现类,体现的是面向接口编程 + 解耦的思想,在以下框架中都运用了此思想:

  • JDBC
  • Servlet 初始化器
  • Spring 容器
  • Dubbo(对 SPI 进行了扩展)

继续看上述代码中的 ServiceLoader.load 方法:

1
2
3
4
5
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取线程上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

线程上下文类加载器是当前线程使用的类加载器,默认是应用程序类加载器。ServiceLoader.load 的内部是由 CLass.forName 调用了线程上下文类加载器完成类加载。具体代码在 ServiceLoader 的内部类 LazyIterator 中。

适用场景:

  • 当高层提供了统一接口让低层去实现,同时又要是在高层加载(或实例化)低层的类时,必须通过线程上下文类加载器来帮助高层的 ClassLoader 找到并加载该类。
  • 当使用本类托管类加载,然而加载本类的 ClassLoader 未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。

自定义类加载器

应用场景

  • 想加载非 classpath 随意路径中的类文件
  • 都是通过接口来实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器

步骤

  1. 继承 ClassLoader 父类
  2. 要遵从双亲委派机制,重写 findClass 方法
    • 不是重写 loadClass 方法,否则不会走双亲委派机制
  3. 读取类文件的字节码
  4. 调用父类的 defineClass 方法来加载类
  5. 使用者调用该类加载器的 loadClass 方法

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClassLoader extends ClassLoader{
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException{
String path = "e:\\myclasspath\\" + name + ".class";
try {
ByteArrayOutputStream os = new ByteArrayOutputStream();
Files.copy(Paths.get(path), os);
// 得到字节数组
byte[] bytes = os.tobyteArray();
// byte[]转化为*.class
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e){
e.printStackTrace();
throw new ClassNotFoundException("类文件未找到", e);
}
}
}
1
2
3
4
5
6
7
8
9
MyClassLoader classLoader = new MyClassLoader();
Class<?> c1 = classLoader.loadClass("name1");
Class<?> c2 = classLoader.loadClass("name1");
System.out.println(c1 == c2); // true
MyClassLoader classLoader2 = new MyClassLoader();
Class<?> c3 = classLoader.loadClass("name1");
System.out.println(c1 == c3); // false,同一个类加载器为true

c1.newInstance(); // 实例化一个name1对象

虚拟机字节码执行引擎

栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

局部变量表以变量槽(Variable Slot)为最小单位,每个 slot 都能存放下一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,这 8 种数据类型,都可以使用 32 位或更小的物理内存来存放。对于 64 位的数据类型 long 和 double,虚拟机会以高位对齐的方式为其分配两个连续的 slot 空间。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出 LIFO 栈。操作数栈的每一个元素可以是任意类型的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈\入栈操作。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。

第一种是执行引擎遇到任意一个方法返回的字节码指令将会退出这个方法,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(只要在本方法的异常表中没有搜索到匹配的异常处理器),就会导致方法退出,这种退出方法的方式称为异常完成出口,这种退出方式不会给它的上层调用者产生任何返回值。

一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时不涉及方法内部的具体运行过程。Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

Java语言半解释半编译执行

Java 语言中,Javac 编译器完成了程序代码到线性的字节码指令流的过程,这一部分动作是在 Java 虚拟机之外进行的,而解释器是在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。

运行期优化

即时编译

分层编译——逃逸分析

1
2
3
4
5
6
7
8
9
10
11
12
public class JIT1 {
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
new Object();
}
long end = System.nanoTime();
System.out.printf("%d\t%d\n",i,(end - start));
}
}
} // 结果耗用时间分几个阶段下降明显

JVM 将执行状态分成了 5 个层次:

  • 0 层,解释执行(Interpreter)
  • 1 层,使用 C1 即时编译器编译执行(不带 profiling)
  • 2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
  • 3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
  • 4 层,使用 C2 即时编译器编译执行

profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的回边次数】等。

即时编译器(JIT)与解释器的区别

  • 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
  • JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
  • 解释器是将字节码解释为针对所有平台都通用的机器码
  • JIT 会根据平台类型,生成平台特定的机器码

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1(提升5倍) < C2(提升10-100倍),总的目标是发现热点代码(hotspot 名称的由来),优化之。

刚才的一种优化手段称之为【逃逸分析】。逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化:

  • 栈上分配:如果确定一个对象不会逃逸出方法之外,让这个对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁。
  • 同步消除:线程同步本身是一个相对耗时的过程,如果确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  • 标量替换:标量是指一个数据已经无法再分解成更小的数据来表示,Java 虚拟机中的原始数据类型都不能再进一步分解,可以称为标量。如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果确定一个对象不会被外部访问,并且这个对象可以拆散的话,那程序真正执行的时
    候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。

可以使用 -XX:-DoEscapeAnalysis 关闭逃逸分析。通过逃逸分析后的对象,可将这些对象直接在栈上进行分配,而非堆上。极大的降低了GC次数,从而提升了程序整体的执行效率。

方法内联

方法内联,它是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。它的行为很简单:把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已

1
2
3
4
private static int square(final int i) {
return i * i;
}
System.out.println(square(9));

如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:

1
System.out.println(9 * 9);

还可以进行常量折叠:

1
System.out.println(81);

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class JIT2 {
// -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining (解锁隐藏参数)打印inlining 信息
// -XX:CompileCommand=dontinline,*JIT2.square 禁止某个如square方法 inlining
// -XX:+PrintCompilation 打印编译信息
public static void main(String[] args) {
int x = 0;
for (int i = 0; i < 500; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
x = square(9);
}
long end = System.nanoTime();
System.out.printf("%d\t%d\t%d\n",i,x,(end - start));
// 方法内联后已经没有了方法调用,运行速度会提升很多
}
}
private static int square(final int i) {
return i * i;
}
}

字段优化

读取优化:

  • 即时编译器会优化实例字段和静态字段的访问,以减少总的内存访问次数
  • 即时编译器将沿着控制流 ,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值
  • 当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么将读取节点替换为该缓存值
  • 当即时编译器遇到对同一字段的存储节点时,会更新所缓存的值
  • 当即时编译器遇到可能更新字段的节点时,它会采取保守的策略,舍弃所有的缓存值
  • 方法调用节点 :在即时编译器看来,方法调用会执行未知代码
  • 内存屏障节点 :其他线程可能异步更新了字段

存储优化:

  • 如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容读取 ,那么即时编译器将消除第一个字段存储

实例:

JMH 基准测试依赖

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>

基准测试代码

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
public class Benchmark1 {
int[] elements = randomInts(1_000);
private static int[] randomInts(int size) {
Random random = ThreadLocalRandom.current();
int[] values = new int[size];
for (int i = 0; i < size; i++) {
values[i] = random.nextInt();
}
return values;
}
@Benchmark
public void test1() {
for (int i = 0; i < elements.length; i++) {
doSum(elements[i]);
}
}
@Benchmark
public void test2() {
int[] local = this.elements;
for (int i = 0; i < local.length; i++) {
doSum(local[i]);
}
}
@Benchmark
public void test3() {
for (int element : elements) {
doSum(element);
}
}
static int sum = 0;
@CompilerControl(CompilerControl.Mode.INLINE)
static void doSum(int x) {
sum += x;
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Benchmark1.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}

首先启用 doSum 的方法内联,测试结果如下(每秒吞吐量,分数越高的更好):

1
2
3
4
Benchmark       Mode Samples    Score Score error Units
t.Benchmark1.test1 thrpt 5 2420286.539 390747.467 ops/s
t.Benchmark1.test2 thrpt 5 2544313.594 91304.136 ops/s
t.Benchmark1.test3 thrpt 5 2469176.697 450570.647 ops/s

接下来禁用 doSum 方法内联

1
2
3
4
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
static void doSum(int x) {
sum += x;
}

测试结果如下:

1
2
3
4
Benchmark       Mode Samples    Score Score error Units
t.Benchmark1.test1 thrpt 5 296141.478 63649.220 ops/s
t.Benchmark1.test2 thrpt 5 371262.351 83890.984 ops/s
t.Benchmark1.test3 thrpt 5 368960.847 60163.391 ops/s

分析:
在刚才的示例中,doSum 方法是否内联会影响 elements 成员变量读取的优化:如果 doSum 方法内联了,刚才的 test1 方法会被优化成下面的样子(伪代码):

1
2
3
4
5
6
7
@Benchmark
public void test1() {
// elements.length 首次读取会缓存起来 -> int[] local
for (int i = 0; i < elements.length; i++) { // 后续 999 次 求长度 <- local
sum += elements[i]; // 1000 次取下标 i 的元素 <- local
}
}

可以节省 1999 次 Field 读取操作,但如果 doSum 方法没有内联,则不会进行上面的优化

反射优化

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Reflect1 {
public static void foo() {
System.out.println("foo...");
}
public static void main(String[] args) throws Exception {
Method foo = Reflect1.class.getMethod("foo");
for (int i = 0; i <= 16; i++) {
System.out.printf("%d\t", i);
foo.invoke(null);
}
System.in.read();
}
}

foo.invoke 前面 0 ~ 15 次调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现。

当调用到第 16 次(从0开始算)时,会采用运行时生成的类代替掉最初的实现,可以通过 debug 得到类名为 sun.reflect.GeneratedMethodAccessor1。由于生成新的类要花比较长的时间,比直接 native 调用要长3倍左右的时间。但是生成类以后,native 调用就会比调用花的时间长 20 倍。

即在经过膨胀超过调用阈值后,会生成一个类,下次反射调用的时候将不会调用 native 方法去完成调用方法的操作,而是直接调用该生成类的方法。

注意
通过查看 ReflectionFactory 源码可知

  • sun.reflect.noInflation 可以用来禁用膨胀(直接生成 GeneratedMethodAccessor1,但首
    次生成比较耗时,如果仅反射调用一次,不划算)
  • sun.reflect.inflationThreshold 可以修改膨胀阈值

内存模型

Java 内存模型,Java Memory Module(JMM) 的意思。它定义了一套多线程读写共享数据(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障。

这一段懒得写了,直接摘录《并发编程》中的内容吧。

Java内存模型

JMM 本身是一个抽象的概念,并不真实存在,它描述的是通过一组规则或规范定制了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。因为它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而能实现平台一致性,以达到 Java 程序能够“一次编写,到处运行”。

由于JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而 Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,此线程间的通讯(传值) 必须通过主内存来完成。

imagedceb6e4934633705.png

JMM 是围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而简历的模型。

有时间专门整理一篇 JMM 相关内容。

原子性(Atomicity)

一个操作不能被打断,要么全部执行完毕,要么不执行

基本类型数据的访问大都是原子操作,long 和 double 类型的变量是 64 位,但是在 32 位 JVM 中,32 位的 JVM 会将 64 位数据的读写操作分为 2 次 32 位的读写操作来进行,这就导致了 long、double 类型的变量在 32 位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

解决原子性问题(如多个线程同时操作一个共享变量),可以使用:

  • 加 synchronized 关键字
  • 使用 JUC 下的 AtomicInteger 类

可见性(visibility)

一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)。

volatile 关键字、synchronized关键字、Lock接口、final 可以实现可见性。

volatile 关键字的特殊规则保证了被 volatile 修饰的变量的值修改后,新值会立刻同步到主内存,每次使用被 volatile 修饰的变量时会立刻从主内存中刷新,因此 volatile 关键字可以保证多线程之间的操作变量的可见性。

synchronized 关键字在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中;在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去。

使用 Lock 接口(拿最常用的实现 ReentrantLock 重入锁)来实现可见性:在方法开始的位置执行 lock() 方法,这和 synchronized 开始位置(Monitor Entry)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中;在方法最后的执行 unlock() 方法,和 synchronized 结束位置(Monitor Exit)有相同的语义,即将工作内存中的变量值同步到主内存中去。

被 final 关键字修饰的变量,在构造函数完成且构造函数中没有把 this 的引用传递出去时,其他线程就可以看到 final 变量的值,实现了可见性。(this 引用逃逸非常危险,其他的线程很可能通过引用访问到只初始化了一半的对象)

有序性(Ordering)

在单线程中,代码的执行时从前往后依次执行的。但是在多线程并发时,程序的执行有可能是无序的。在本线程内观察,所有操作都是有序的。如果在一个线程中观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序、工作内存和主内存同步延迟现象(单线程中不存在)。

一个最经典的例子就是银行汇款问题,一个银行账户存款 100,这时一个人从该账户取 10 元,同时另一个人向该账户汇 10 元,那么余额应该还是 100。那么此时可能发生这种情况,A 线程负责取款,B 线程负责汇款,A 从主内存读到 100,B 从主内存读到 100,A 执行减 10 操作,并将数据刷新到主内存,这时主内存数据 $100-10=90$,而 B 内存执行加 10 操作,并将数据刷新到主内存,最后主内存数据 $100+10=110$,显然这是一个严重的问题,我们要保证 A 线程和 B 线程有序执行,先取款后汇款或者先汇款后取款,此为有序性。

可以通过 volatile 关键字和 synchronized 关键字来保证多线程操作的有序性,volatile 关键字通过加入内存屏障来禁止指令的重排序优化,而 synchronized 关键字通过加锁来允许同一时间只有一个线程对共享变量进行操作。