JVM学习

JVM学习

一、什么是JVM

定义

Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)

好处

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收机制
  • 数组下标越界检查(比如C语言本身不提供数组下标越界检查,可能会导致覆盖其他代码的内存,这是十分严重的)
  • 多态(虚方法表)

比较

JDK = (JRE + 编译工具(javac、javap)) = (JVM + 基础类库(java.util..) + 编译工具(javac、javap))

JavaEE = (JavaSE + 应用服务器(tomcat ...)) = (JDK + ID工具(IDEA...) + 应用服务器(tomcat ...))

常见JVM

JVM是一套规范,只要符合这一套规范就可以称为JVM。比较常用的是SunOracle公司的HotSpotJVM。并且本篇博客以HotSpot为准。

整体架构

JVM整体结构主要有类加载器模块、内存架构以及执行引擎。执行流程是,java源代码编译为java二进制字节码文件,然后通过类加载器加载进JVM 去运行,类放在方法区,类创建的实例对象放在堆里,而堆里的实例对象调用方法时又会用到虚拟机栈、程序计数器和本地方法栈。方法运行时每一行代码是由执行引擎中的解释器逐行执行,方法里的热点代码(也就是频繁调用的代码)会被JIT Compiler即时编译器进行编译执行(优化后的执行)。而垃圾回收模块会对堆里面不再引用的对象进行垃圾回收。当然还有一些java代码不方便实现的功能,必须调用底层操作系统的功能,所以需要通过本地方法接口来调用操作系统调用。

二、内存结构

1、程序计数器

Program Counter Register程序计数器(寄存器),程序计数器是java对于物理硬件的屏蔽和抽象,在物理上是通过寄存器来实现的。寄存器是CPU里读取速度最快的单元,又因为读取指令地址是非常频繁的,所以JVM在设计时就把寄存器当做了程序计数器用它来存储地址。

作用

用于保存JVM指令中下一条所要执行的指令的地址,比如下图所示二进制字节码文件中jvm指令前的数字就是jvm指令的地址。当解释器将当前jvm指令编译成机器码之后,会去程序计数器获得下一条jvm指令的地址,当解释器获得了jvm指令地址时,程序计数器会加载在下一条jvm指令地址,以此类推。

Java是解释执行还是编译执行?

这个问题并没有统一的答案,JVM规范并没有强制要求JVM实现应该使用哪种方式来执行程序,只能说不同的JVM实现的方式不一样。有纯解释执行的、纯编译执行的(JRockit)、还有解释+编译两者混用的(HotSpot)

解释器与编译器两者各有优势:

  1. 当程序需要迅速启动时,解释器可以发挥优势,省去编译的时间,立即执行。
  2. 程序启动后,随着时间的推移,编译器开始发挥作用,JVM会将越来越多的热点代码编译成本地代码,减少解释器的中间损耗,获得更高的执行效率。

java源代码会被先编译成二进制字节码文件,然后通过解释器再编译为机器码,最后机器码被CPU运行。二进制字节码(也叫jvm指令)java源代码在所有平台是一样的。

特点

  • 线程私有

    • 当多个线程运行时,CPU的调度器组件会为每个线程分配时间片,当当前线程的时间片使用完以后,并且当前线程没有执行结束那么会把当前线程的状态进行一个暂存,CPU就会去执行另一个线程中的代码。
    • 程序计数器是每个线程私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令。
  • 在JVM规范中唯一一个不会存在内存溢出的区,其他的区都可能会出现内存溢出。

    首先,我们熟悉的栈和堆,都是可以通过运行时对内存需求的扩增导致内存不够用的情况。比如某个线程递归调用,随着调用层次的加深,可能会出现栈空间不足的情况,这时候如果可以动态扩增,jvm就会向申请更多的内存空间来扩充栈,当没有更多的内存空间得以申请的时候,就会发生OutOfMemoryError。

    但是,程序计算器仅仅只是一个运行指示器,它所需要存储的内容仅仅就是下一个需要待执行的命令的地址,无论代码有多少,最坏情况下死循环也不会让这块内存区域超限,因为程序计算器所维护的就是下一条待执行的命令的地址,所以不存在OutOfMemoryError。只存下一个字节码指令的地址,消耗内存小且固定,无论方法多深,他只存一条。只针对一个线程,随着线程的结束而销毁。

2、虚拟机栈

定义

  • 每个线程运行需要的内存空间,称为虚拟机栈
  • 每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存(参数,局部变量,返回地址(当前方法执行结束后,需要返回到的代码地址)等等)
  • 每个线程只能有一个活动栈帧(虚拟机栈栈顶的栈帧),对应着当前正在执行的方法

演示

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
method1();
}

private static void method1() {
method2(1, 2);
}

private static int method2(int a, int b) {
int c = a + b;
return c;
}
}

在控制台中可以看到,主类中的方法在进入虚拟机栈的时候,符合栈的特点

问题辨析

  • 垃圾回收是否涉及栈内存?
    • 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
  • 栈内存的分配越大越好吗?
    • 不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

      通过-Xsssize 设置线程栈大小(以字节为单位)。用字母k或K表示KB、m或M表示MB,用G表示GB。默认值取决于平台(windows默认值取决于虚拟内存)。需要注意的是-Xss和size之间不允许有空格。

      在使用javac Xxx.java生成字节码文件后,java -Xss 1m Xxx就可以指定栈大小运行程序。

  • 方法内的局部变量是否是线程安全的?
    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全
    • 如果如果局部变量引用了(堆里的)对象,并逃离了方法的作用范围,则需要考虑线程安全问题
      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
      import java.util.List;

      public class Test2 {
      public static void main(String[] args) {
      StringBuilder sb = new StringBuilder();
      sb.append(4);
      sb.append(5);
      sb.append(6);
      new Thread(() -> {
      m2(sb);
      }).start();
      }
      public static void m1() {
      StringBuilder sb = new StringBuilder();
      sb.append(1);
      sb.append(2);
      sb.append(3);
      System.out.println(sb.toString());
      }
      public static void m2(StringBuilder sb) {
      sb.append(1);
      sb.append(2);
      sb.append(3);
      System.out.println(sb.toString());
      }
      public static StringBuilder m3() {
      StringBuilder sb = new StringBuilder();
      sb.append(1);
      sb.append(2);
      sb.append(3);
      return sb;
      }
      }

      上面例子中,m1()一定是线程安全的;m2()不一定是线程安全的,因为sb是作为参数传进来的,sb可以被其他线程访问,又因为StringBuilder本身就不是线程安全的,所以m2()不是线程安全的;m3()不一定是线程安全的,因为sb是作为返回值返回了,sb可以被其他线程访问,又因为StringBuilder本身就不是线程安全的,所以m3()不是线程安全的

      m2()发生了线程逃逸;而m3()发生了方法逃逸。方法逃逸:在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。线程逃逸:这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。对象逃出了当前线程。

栈内存溢出

Java.lang.stackOverflowError异常, 栈内存溢出

发生原因

  • 虚拟机栈中,栈帧过多(无限递归)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import java.util.List;

    public class Test2 {
    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();
    }
    }

    在IDEA的VM options中,通过-Xss减小栈大小,可以观察到count减小了。

  • 每个栈帧所占用过大

    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
    57
    58
    import java.util.Arrays;
    import java.util.List;

    public class Test2 {
    public static void main(String[] args) {
    Dept d = new Dept();
    d.setName("market");

    Emp e1 = new Emp();
    e1.setName("zhang");
    e1.setDept(d);

    Emp e2 = new Emp();
    e2.setName("li");
    e2.setDept(d);

    d.setEmps(Arrays.asList(e1, e2));

    ObjectMapper mapper = new ObjectMapper();
    System.out.println(mapper.writeValueAsString(d));
    }
    }

    class Emp {
    private String name;
    @JsonIgnore
    private Dept dept;

    public String getName() {
    return name;
    }
    public void setName(String name) {
    this.name = name;
    }
    public Dept getDept() {
    return dept;
    }
    public void setDept(Dept dept) {
    this.dept = dept;
    }
    }

    class Dept {
    private String name;
    private List<Emp> emps;
    public String getName() {
    return name;
    }
    public void setName(String name) {
    this.name = name;
    }
    public List<Emp> getEmps() {
    return emps;
    }
    public void setEmps(List<Emp> emps) {
    this.emps = emps;
    }
    }

    如果不写@JsonIgnore那么,员工类和部门类之间存在循环引用,那么在转换成json时,会导致栈内存溢出。

线程运行诊断

案例1:CPU占用过高

  • Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程 nohup java 某个二进制字节码文件 & & : 指在后台运行,但当用户推出(挂起)的时候,命令自动也跟着退出。 nohup : 不挂断的运行,注意并没有后台运行的功能,就是指,用nohup运行命令可以使命令永久的执行下去,和用户终端没有关系,例如我们断开SSH连接都不会影响他的运行,注意了nohup没有后台运行的意思;&才是后台运行。
    • top命令,查看是哪个进程占用CPU过高,然后通过ps -eo pid, tid(线程id), %cpu | grep 刚才通过top查到的进程号 通过ps命令进一步查看是哪个线程占用CPU过高(-e 打印正在运行的所有进程相关信息,-o格式化输出)
    • jstack 进程id 可以列出该进程中所有线程,通过查看进程中的线程的nid,刚才通过ps命令看到的tid来对比定位,注意jstack查找出的线程id是16进制的需要转换。然后查看是哪一个类中的哪一行代码出现了问题。需要注意的是,jstack列出的tid指的是jvm中java线程id,nid才是操作系统映射的线程id,每一个java线程都有一个对应的操作系统线程。

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

nohup java 某个二进制字节码文件 & 会返回进程id,然后使用jstack pid列出该进程中所有线程,如果有死锁,会在最后显示在哪几行代码出现死锁。

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 Test2 {
static Object a = new Object();
static Object b = new Object();
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
synchronized (a) {
try {
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println("get a and b");
}
}
}).start();
Thread.sleep(1000);
new Thread(() -> {
synchronized (b) {
synchronized (a) {
System.out.println("get a and b");
}
}
}).start();
}
}

3、本地方法栈

一些带有native关键字的方法(不是由java编写的方法)就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法。这些本地方法运行时,使用的内存就是本地方法栈。本地方法是非常多的,不管是java类库里,还是执行引擎里都会频繁调用本地方法。

4、堆

上面讲解的程序计数器、虚拟机栈以及本地方法栈都是线程私有的,且没有垃圾回收机制;而堆和方法区都是线程共有的,而且有垃圾回收机制。

定义

通过new关键字创建的对象都会被放在堆内存

特点

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制

堆内存溢出

java.lang.OutofMemoryError : java heap space. 堆内存溢出。被引用的对象过多导致堆内存溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Test2 {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String str = "hello";
while (true) {
list.add(str);
str += str;
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}

通过-Xmxsize指定最大堆内存空间;-Xmssize初始的堆内存空间的大小。

堆内存诊断

  • jps,查看当前系统中有哪些java进程。

    jps直接查看有哪些java进程。

  • jmap,查看堆内存占用情况,不过只能查看某一时刻的堆内存占用情况,但是有一个缺点就是在debug模式下抓不到变量个数。

    jmap -heap pid查看该进行堆信息。-heap 打印heap的概要信息,GC使用的算法,heap(堆)的配置及JVM堆内存的使用情况.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Test2 {
    public static void main(String[] args) throws InterruptedException {
    System.out.println("1... jmap -heap pid查看堆内存占用");
    Thread.sleep(30000);
    byte[] array = new byte[1024 * 1024 * 10]; //10MB
    System.out.println("2... jmap -heap pid查看堆内存占用");
    Thread.sleep(30000);
    array = null;
    System.gc();
    System.out.println("3... jamp -heap pid查看堆内存占用");
    Thread.sleep(1000000);
    }
    }

    可以看到堆内存的新生代内存占用从1的低点上升到2的高点,最后又下降3的低点。

    jmap -dump:format=b,live,file=filename.bin pid-dump选项是将当前堆内存转储,format=b是指储存格式为二进制,live是指在存储前主动做一次GC,file=filename.bin是指存储的文件名。存储的文件可以使用eclipse的工具MAT查看。

  • jconsole,图形界面的,多功能的检测工具,可以连续检测

    jconsole然后连接对应进程,就可以看到图形界面的堆内存使用情况。

    仍然使用上面的Test2代码,可以看到下图中的上升和下降,分别对应分配10MB数组内存和手动gc。

    同样的,在线程的详细信息里,可以检测死锁,查看死锁具体情况。

    此外,jconsole图形界面,还可以手动执行gc,如下图所示:

  • jvisualvm

    案例:垃圾回收后,内存占用仍然很高。

    jvisualvm命令,可以像jconsole一样可视化堆内存等等,但是功能更加强大,使用堆dump保存当前堆的快照也就是堆转储功能,但是jdk8以上已经不自动安装了。

  • jinfo:实时查看指定Java pid 进程的虚拟机指定参数情况。

    jinfo -flag option pid会返回指定参数的情况,[+|-]name 或者 name=value。

    例如:

    1
    2
    3
    jinfo -flag CMSInitiatingOccupancyFraction 1444
    输出
    -XX:CMSInitialtingOccupancyFraction=85

5、方法区

定义

Oraclejdk1.8文档

JVM方法区是所有java虚拟机线程共享的区域。方法区类似于用于传统语言的编译代码的存储区,或者类似于操作系统进程中的“text”段。它存储每个类的结构,如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。

方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但还是要看各厂商的具体实现,各厂商不一定遵从。HotSpot在JDK1.8之前,方法区是在堆内存中使用永久代作为具体实现,而在JDK1.8及以后,使用在本地内存的元空间作为具体实现。

结构

Java7及以前版本的Hotspot中方法区位于永久代中。同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。如下图所示:

内存溢出

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
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

//设置元空间内存大小 -XX:MaxMetaspaceSize=size
//设置永久代内存大小 -XX:MaxPermSize=size
public class Test2 extends ClassLoader {//可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Test2 test = new Test2();
for (int i = 0; i < 10000; i++, j++) {
//ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
//jdk版本号,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); //Class对象
}
} finally {
System.out.println(j);
}
}
}

//设置元空间内存大小 -XX:MaxMetaspaceSize=size
//设置永久代内存大小 -XX:MaxPermSize=size

方法区内存溢出,也会抛出OutofMemoryError异常。

  • 1.8以前会导致永久代内存溢出OutofMemoryError : PermGen space
  • 1.8及以后会导致元空间内存溢出OutofMemoryError : Metaspace

实际场景:Spring和Mybatis框架都使用了大量的字节码技术,都会使用到cglib都会动态加载类,可能会导致方法区内存溢出。

常量池

二进制字节码的组成:类的基本信息、常量池、类的方法定义(包含了虚拟机指令)

通过反编译来查看类的信息

1
2
3
4
5
public class Test2{
public static void main(String[] args) {
System.out.println("Hello World");
}
}
  • 获得对应类的.class文件
    1
    java ./Test.java
  • 在控制台输入 javap -v 类的绝对路径,-v参数显示反编译的详细信息。
    1
    javap -v ./Test2.class
  • 然后能在控制台看到反编译以后类的信息了
    • 类的基本信息
    • 常量池
    • 虚拟机中执行编译的方法(框内的是真正编译执行的内容,**#号的内容需要在常量池中查找**)

运行时常量池

  • 常量池
    • 就是一张表(如上图中的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量(如字符串、整数、布尔值等等)信息
  • 运行时常量池
    • 常量池是.class文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址(#num)变为真实地址

常量池与串池的关系

串池StringTable

特征

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象

  • 利用串池的机制,来避免重复创建字符串对象

  • 字符串变量拼接的原理是StringBuilder

  • 字符串常量拼接的原理是编译器优化

  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中

  • 只有字节码命令ldc加载字符串,如果该字符串对象在串池中没有,才会将字符串对象放入串池。只有在代码里,存在的字符串常量(包括拼接形成的字符串常量,或者主动Intern()的字符串)才会被加载入串池。

    1
    2
    3
    4
    5
    public class Test2{
    public static void main(String[] args) {
    String s1 = new String("a") + new String("b");
    }
    }

    这个代码总共形成了五个对象,串池中的["a", "b"],以及堆中的new String("a") new String("b") new String("ab")

  • 注意:无论是串池还是堆里面的字符串,都是对象

用来放字符串对象且里面的元素不重复

1
2
3
4
5
6
7
public class StringTableStudy {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}

当运行二进制字节码文件时,常量池中的信息,都会被加载到运行时常量池中,但这是a b ab 仅是常量池中的符号,还没有成为java字符串对象。只有执行到那一行字节码指令时,才会被加载入串池并成为对象,这是一个懒惰的行为。

1
2
3
4
5
6
7
0: ldc           #2                  // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return

当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,去串池中查找是否存在“a”对象,如果没有则放入串池(StringTable)中(hashtable结构且不可扩容)

当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,去串池中查找是否存在“b”对象,如果没有则放入串池中

当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,去串池中查找是否存在“ab”对象,如果没有则放入串池中

最终StringTable [“a”, “b”, “ab”]

astore_1astore_2astore_3分别是将加载的字符串放入局部变量表LocalVaribleTable中槽Slot1、2和3中。

注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

使用拼接字符串变量对象创建字符串的过程

1
2
3
4
5
6
7
8
9
public class StringTableStudy {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
//拼接字符串对象来创建新的字符串
String s4 = s1 + s2;
}
}

反编译后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
	 Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a 在串池中寻找"a",如果没有放入串池
2: astore_1 把串池对象"a"的地址放入局部变量表中的1位置
3: ldc #3 // String b 在串池中寻找"b",如果没有放入串池
5: astore_2 把串池对象"b"的地址放入局部变量表中的2位置
6: ldc #4 // String ab 在串池中寻找"ab",如果没有放入串池
8: astore_3 把串池对象"ab"的地址放入局部变量表中的3位置
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V 无参构造器
16: aload_1 从局部变量表中的1位置获得变量
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder; 调用append()
20: aload_2 从局部变量表中的2位置获得变量
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder; 调用append()
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
ing; 调用toString()
27: astore 4 把toString()的结果放入局部变量表中的4位置
29: return

通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()

最后的toString方法的返回值是一个新的字符串,是使用new String()创建的字符串对象,但字符串的和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中

1
2
3
4
String s3 = "ab";
String s4 = a+b;
//结果为false,因为s3是存在于串池之中,s4是由StringBuffer的toString方法所返回的一个对象,存在于堆内存之中
System.out.println(s3 == s4);

使用拼接字符串常量对象的方法创建字符串

1
2
3
4
5
6
7
8
9
10
public class StringTableStudy {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = a+b;
//使用拼接字符串的方法创建字符串
String s5 = "a" + "b";
}
}

反编译后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 	  Code:
stack=2, locals=6, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
ing;
27: astore 4
29: ldc #4 // String ab s5初始化时直接从常量池中获取字符串,并在串池中寻找"ab",如果没有将"ab"对象加入串池
31: astore 5 将上一步的返回结果放入局部变量表中Slot为5的位置
33: return
  • 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac命令在编译期会进行优化,结果已在编译期确定为ab,而在串池中创建”ab”对象的时候已经在串池中放入了“ab”,所以s5直接从串池中获取值,所以进行的操作和 s3 = “ab” 一致。
  • 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
字符串对象创建是懒惰的
1
2
3
4
5
6
7
8
9
public class Test2{
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
}
}

在第三行和第四行打上端点,然后利用debug工具进行调试,发现字符串对象的创建确实是懒惰的。

JDK1.8中,String.intern()方法

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中。注意,这个放入动作是指,将字符串对象的地址放入串池,而不是说在串池这个地方放入字符串对象。

  • 如果串池中没有该字符串对象,则放入成功
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象。大致含义是,当调用intern方法时,如果池中已经包含了一个由equals(Object)方法确定的与该字符串对象相等的字符串,则返回池中的字符串。否则,该字符串对象将被添加到池中,并返回对该字符串对象的引用。

注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

例1

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//调用str的intern方法,这时串池中没有"ab",则会将该字符串对象放入到串池中,此时堆内存与串池中的"ab"是同一个对象
String str2 = str.intern();
//给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回
String str3 = "ab";
//因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true
System.out.println(str == st2);
System.out.println(str == str3);
}
}

例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
public static void main(String[] args) {
//此处创建字符串对象"ab",因为串池中还没有"ab",所以将其放入串池中
String str3 = "ab";
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//此时因为在创建str3时,"ab"已存在与串池中,所以放入失败,但是会返回串池中的"ab"
String str2 = str.intern();
//false
System.out.println(str == str2);
//false
System.out.println(str == str3);
//true
System.out.println(str2 == str3);
}
}
JDK1.6中String.intern()方法

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再将新对象的地址放入到串池中
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//调用str的intern方法,这时串池中没有"ab",则会创建一个新的"ab"并将该新对象地址放入串池中,此时堆内存与串池中的"ab"不是同一个对象
String str2 = str.intern();
//给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回
String str3 = "ab";
//因为堆内存与串池中的"ab"不是同一个对象,所以以下两条语句打印的都为false
System.out.println(str == st2);
System.out.println(str == str3);
}
}

StringTable位置

为什么JDK1.8要把StringTable从永久代中移到堆中呢?因为永久代的内存回收效率很低,需要fullGC时,才会触发垃圾回收。fullGC需要等到老年代的内存不足,才会触发,触发时机比较晚,但是StringTable的使用效率很频繁,如果StringTable的回收效率不高会导致占用大量内存,进而导致永久代的内存不足。故而从JDK1.7开始,StringTable被移到了堆中,只需要minorGC就可以触发垃圾回收。

那么如何通过实验来证明上述的观点呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.ArrayList;
import java.util.List;

public class Test2{
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int j = 0;
try {
for (int i = 0; i < 260000; j++) {
list.add(("" + j).intern());
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(j);
}

}
}

然后在jdk1.8中设置最大堆内存 -Xmx10m 和 打开GC频率限制 -UseGCOverheadlimit(防止频繁发生GC而发生异常OutOfMemoryError:GC overhead limit exceeded。然后运行程序会发生,java.lang.OutOfMemoryError: Java heap space

在jdk1.6中设置最大永久代内存 -XX:MaxPermSize=10m,然后运行会发生OutofMemoryError : PermGen space

StringTable 垃圾回收

StringTable在内存紧张时,StringTable中那些没有被引用的字符串常量会被垃圾回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.ArrayList;
import java.util.List;

public class Test2{
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
try {
for (int j = 0; j < 100000; j++) {
String.valueOf(j).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}

}
}

设置最大堆内存-Xmx10m;在jvm进程退出时会输出SymbolTable statistics及StringTable statistics -XX:+PrintStringTableStatistics; 在每次GC时打印详细消息 -XX:+PrintGCDetails; 显示有关每个垃圾收集(GC)事件的信息。 -verbose:gc

StringTable调优

  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,那么哈希碰撞的概率就会减小,查找的速度也会变快。如果桶的个数比较少,那么哈希碰撞的概率增大,链表(红黑树)的长度变长,查找和增删的效率变慢。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import java.io.*;

    public class Test2{
    public static void main(String[] args) throws FileNotFoundException, UnsupportedEncodingException {
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))){
    //try()中的代码一般放的是对资源的声明,try代码块执行完后,()中的资源就会自动关闭;如果{}中的代码出项了异常,()中的资源也会自动关闭。jdk1.7以上可用
    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 (Throwable e) {
    e.printStackTrace();
    }
    }
    }

    -XX:StringTableSize=xx 设置StringTable桶的个数 Number of buckets in the interned String table

    -XX:+PrintStringTableStatistic 在jvm进程退出时会输出SymbolTable statistics及StringTable statistics

    可以观察到,当StringTable的数量减少时,耗费的总时间增大。

  • 考虑是否需要将字符串对象入池,可以通过intern()方法返回串池对象,防止大量重复字符串占用堆内存。
    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
    import java.io.*;
    import java.util.ArrayList;
    import java.util.List;

    public class Test2{
    public static void main(String[] args) throws IOException {

    List<String> address = new ArrayList<>();
    System.in.read(); //从标准输入读入一个字节,起到停顿作用
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))){
    //try()中的代码一般放的是对资源的声明,try代码块执行完后,()中的资源就会自动关闭;如果{}中的代码出项了异常,()中的资源也会自动关闭。jdk1.7以上可用
    String line = null;
    long start = System.nanoTime();
    while (true) {
    line = reader.readLine();
    if (line == null) {
    break;
    }
    address.add(line.intern());
    }
    System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
    }
    System.in.read();
    }
    }
    使用visualvm查看,使用串池对象和堆内对象占用内存比较,发现使用串池对象大大减小了内存占用。

6、直接内存

  • 不属于JVM内存,而属于操作系统内存,常见于NIO操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受JVM内存回收管理

文件读写流程

java代码本身不具备读取文件的能力,需要调用操作系统提供的接口。所以需要切换到从用户态切换到内核态去读取文件,但是为了高效利用内存,所以需要多次调用操作系统函数来完成整个文件的读取。同样,内存方面也是需要先从磁盘读取到系统缓存区(操作系统内存中),但是系统内存Java代码也不能直接使用,故而需要再读到Java堆内存的缓冲区中。为了高效利用内存,也需要多次重复这个过程。

使用DirectBuffer之后,

直接内存是操作系统和Java代码都可以访问的一块区域,无需将代码从系统内存复制到Java堆内存,从而成倍提高了效率。例如下面的代码:

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
57
58
59
60
61
62
63
64
package cn.itcast.jvm.t1.direct;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
* 演示 ByteBuffer 作用
*/
public class Demo1_9 {
static final String FROM = "./bigFile.zip";
static final String TO = "./bigFileCopy.zip";
static final int _1Mb = 1024 * 1024;

public static void main(String[] args) {
io(); // io 用时:1535.586957 1766.963399 1359.240226
directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
}

private static void directBuffer() {
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
FileChannel to = new FileOutputStream(TO).getChannel();
) {
ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb); //分配直接内存
while (true) {
int len = from.read(bb);
if (len == -1) {
break;
}
bb.flip();
to.write(bb);
bb.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
}

private static void io() {
long start = System.nanoTime();
try (FileInputStream from = new FileInputStream(FROM);
FileOutputStream to = new FileOutputStream(TO);
) {
byte[] buf = new byte[_1Mb];
while (true) {
int len = from.read(buf);
if (len == -1) {
break;
}
to.write(buf, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("io 用时:" + (end - start) / 1000_000.0);
}
}

直接内存溢出

会抛出java.lang.OutOfMemoryError : Direct buffer memory异常

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
package cn.itcast.jvm.t1.direct;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;


/**
* 演示直接内存溢出
*/
public class Demo1_10 {
static int _100Mb = 1024 * 1024 * 100;

public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
// 方法区是jvm规范, jdk6 中对方法区的实现称为永久代
// jdk8 对方法区的实现称为元空间
}
}

释放原理

直接内存不是由JVM管理的,故而不能使用那些检测JVM的工具来观察,需要使用操作系统的监控程序来观察。直接内存的回收不是通过JVM的垃圾回收来释放的,而是必须通过unsafe.freeMemory来手动释放(不推荐普通程序员直接使用Unsafe类,都是JDK内部使用)。

但是我们观察到,调用System.gc()会释放掉直接内存。不是说JVM的垃圾回收不会释放直接内存吗?这是为什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.IOException;
import java.nio.ByteBuffer;

public class Demo1_26 {
static int _1Gb = 1024 * 1024 * 1024;

public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();
System.out.println("开始释放...");
byteBuffer = null;
System.gc(); // 显式的垃圾回收,Full GC
System.in.read();
}
}

下面展示,Unsafe类申请和释放直接内存的方式。

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
package cn.itcast.jvm.t1.direct;

import sun.misc.Unsafe;

import java.io.IOException;
import java.lang.reflect.Field;

/**
* 直接内存分配的底层原理:Unsafe
*/
public class Demo1_27 {
static int _1Gb = 1024 * 1024 * 1024;

public static void main(String[] args) throws IOException {
Unsafe unsafe = getUnsafe();
// 分配内存
long base = unsafe.allocateMemory(_1Gb); //返回直接内存的地址
unsafe.setMemory(base, _1Gb, (byte) 0);
System.in.read();

// 释放内存
unsafe.freeMemory(base);
System.in.read();
}

public static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}

直接内存回收机制

1
2
//通过ByteBuffer申请1M的直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);

申请直接内存,但JVM并不能回收直接内存中的内容,它是如何实现回收的呢?

allocateDirect的实现

1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

DirectByteBuffer类

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
DirectByteBuffer(int cap) {   // package-private

super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try {
base = unsafe.allocateMemory(size); //申请内存
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
att = null;
}

Cleaner类继承了PhantomReference<Object>类,它是一个虚引用类,Cleaner类的create方法会将后面的回调任务对象(Deallocator类对象,因为该类实现了Runnable接口,所以叫任务对象)关联到前面的DirectByteBuffer类对象上,同时回调任务对象在初始化时就收到了直接内存的地址。

虚引用类的特点是,当它关联的对象被垃圾回收时,会触发虚引用类对象的clean()方法,当然这个clean()方法不是在主线程执行的,而是在ReferenceHandler线程上执行的(ReferenceHandler是一个守护线程,ReferenceHandler线程专门在后台检测这些虚引用对象,一旦虚引用对象关联的实际对象被垃圾回收了,就会调用虚引用对象的clean()方法,clean()方法中会去执行回调任务对象的run()方法)。

解读:这里调用了一个Cleaner的create方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是DirectByteBuffer)被回收以后,就会调用Cleaner的clean方法,来清除直接内存中占用的内存。

Cleaner虚引用类的clean()方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void clean() {
if (remove(this)) {
try {
this.thunk.run(); //调用run方法,thuck对象就是Deallocator类的回调任务对象
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}

System.exit(1);
return null;
}
});
}

Deallocator类的回调任务对象的run方法,如下:

1
2
3
4
5
6
7
8
9
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address); //释放直接内存
address = 0;
Bits.unreserveMemory(size, capacity);
}

直接内存的回收机制总结

  • 使用了Unsafe类来完成直接内存的分配回收,回收需要主动调用Unsafe类的freeMemory()方法
  • ByteBuffer的实现内部使用了Cleaner(虚引用)来检测ByteBuffer。一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler线程来调用Cleaner的clean方法调用回调任务对象的run方法来使用freeMemory方法释放内存。

禁用显示垃圾回收对直接内存的影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.IOException;
import java.nio.ByteBuffer;

/**
* 禁用显式回收对直接内存的影响
*/
public class Demo1_26 {
static int _1Gb = 1024 * 1024 * 1024;

/*
* -XX:+DisableExplicitGC 显式的
*/
public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();
System.out.println("开始释放...");
byteBuffer = null;
System.gc(); // 显式的垃圾回收,触发Full GC
System.in.read();
}
}

-XX:+DisableExplicitGC 禁用显式的垃圾回收

显式垃圾回收,即System.gc(),会触发Full GC。而Full GC不光要回收新生代,也会回收老年代,会造成程序暂停时间比较长,影响程序性能。为了防止一些程序员不小心在自己代码里经常显式垃圾回收影响程序性能,需要加上这个虚拟机参数来进行JVM调优。

因为禁用了显式垃圾回收,可能会出现当前内存比较充裕,使得ByteBuffer对象得不到垃圾回收,直接内存也不会被释放,导致直到下次JVM自动垃圾回收之前直接内存长时间的占用系统内存。这种情况下,需要利用反射得到Unsafe对象,来直接调用freeMemory方法来手动释放直接内存。

三、垃圾回收

1、如何判断对象可以回收

这里有两种方法可以判断对象是否可回收,分别是引用计数法和可达性分析算法。

引用计数法

引用计数法,就是如果一个对象如果被一个变量引用,那么这个对象的引用计数就加1。如果一个变量不再引用该对象了,就将该对象的引用计数减1。那么如果一个对象的引用计数变为0,那么就意味着没有变量引用该对象了,可以作为垃圾进行回收了。

弊端:循环引用时,两个对象的计数都为1,且这两个对象没有其他变量引用,导致两个对象都无法被释放。这就导致垃圾得不到及时的回收。

因为这种循环引用的弊端,JVM并没有采用引用计数法来判断对象是否可以垃圾回收。

可达性分析算法

  • JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
  • 可以作为GC Root的对象
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。譬如当前正在运行的方法所使用到的参数、局部变量、临时变量等。 
    • 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
    • 方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
    • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointerException、OutOfMemoryError)等,还有系统类加载器。
    • 所有被同步锁(synchronized关键字)持有的对象。
    • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
    • 除了上面这些固定的GC Roots集合外,根据用户所选的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

五种引用

下图中实线是强引用,虚线为其他引用。

强引用

强引用是最传统的“引用”定义,是指在程序代码之中普遍存在的引用赋值,即类似于Object obj = new Object()这种引用关系,只要沿着GC Root的强引用关系能够找到该对象,那么无论在任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

只有GC Root都不引用(包括间接强引用)该对象时,才会回收强引用对象

  • 如上图B、C对象都不引用A1对象时,A1对象才会被回收
软引用

软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了SoftReference类来实现软引用。

当GC Root指向(包括间接指向)软引用对象时,在内存不足时,会回收软引用所引用的对象

  • 如上图如果B对象不再引用A2对象且内存不足时,软引用所引用的A2对象就会被回收
软引用的使用
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
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
* 演示软引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Demo2_3 {

private static final int _4MB = 4 * 1024 * 1024;

public static void soft() {
//使用软引用对象 list和SoftReference之间是强引用关系,而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()); //获得软引用对象指向的实际对象
}
}
}

输出如下:

1
2
3
4
5
null
null
null
null
[B@2503dbd3

如果在垃圾回收时发现内存不足,在回收软引用所指向的对象时,软引用本身不会被清理。软引用对象本身也是需要占用内存的,虽然占用内存少,如果想要清理软引用,需要使用引用队列

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
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
* 演示软引用, 配合引用队列
*/
public class Test2 {
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) {
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("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}
}
}

大概思路为:查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除(即切断软引用对象的强引用关系,使得软引用对象可以被回收)。

弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被软引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会收回掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。

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

  • 如上图如果B对象不再引用A3对象,则A3对象会被回收

需要注意的是,弱引用是指发生GC就可以被回收,但不意味着发生GC一定被回收,但是Full GC一定会清除所有弱引用指向的对象。比如:

JAVA内存模型中,弱引用对象在发生GC的时候就会被GC线程回收,但并不意味着在年轻代就会被回收,当JVM垃圾回收器的GC thread还没有执行到回收垃到时刻,如果此时被修饰为弱引用的对象已经通过某个途径copy到了老年代(如:该弱引用对象占独立内存块比较大,首次放入堆内存的时候,由于年轻代的Eden和From zone无足够连续内存空间存放,在老年代的担保策略下,直接存入old generation),此时此刻,这个弱引用对象就存在于老年代,当下一次full GC发生时,被GC线程回收掉,所以不能说弱引用是在young GC回收。

软引用对象一般会经历多次GC,自然晋升到old generation,一般情况下,当内存不足时,才会在GC线程运行时,经由标记算法,一次回收此类对象,因此可以说,软引用对象一般都在full gc过程中被回收。

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
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

/**
* 演示弱引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Test2 {
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) {
// list --> WeakReference --> byte[]
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
for (WeakReference<byte[]> w : list) {
System.out.print(w.get()+" ");
}
System.out.println();

}
System.out.println("循环结束:" + list.size());
}
}

弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference

虚引用

虚引用也被称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2班之后提供了PhantomReference类来实现虚引用。

当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中,虚引用对象所在的引用队列会由ReferenceHandler线程定时查看是否有新入队的Cleaner(该类继承自PhantomReference)对象,如果有那么调用Cleaner对象的clean()方法。

  • 虚引用的一个体现是释放直接内存所分配的内存,当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存
  • 如上图,B对象不再引用ByteBuffer对象,ByteBuffer就会被回收。但是直接内存中的内存还未被回收。这时需要将虚引用对象Cleaner放入引用队列中,然后调用它的clean方法来释放直接内存
终结器引用

所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,虚拟机会创建该对象的终结器引用对象,并会先将终结器引用对象放入引用队列中,再由一个优先级很低的finalizeHandler线程在某些时机查看引用队列中是否存在终结器引用对象,如果有,则根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,等到下一次垃圾回收时该对象所占用的内存就可以真正地被垃圾回收了需要注意的是,这种方式效率很低,第一次垃圾回收时需要先将终结器引用对象加入引用队列,并且finalizeHandler线程优先级很低,可能要等很久才能等到finalizeHandler线程,之后finalize方法被调用,下一次垃圾回收才能真正回收对象的内存。finalize方法已经过时,不推荐使用,同样也不推荐使用终结器引用,try-finally或者其他方式可以做的更好。

  • 如上图,B对象不再引用A4对象。这时终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize方法。调用以后,该对象就可以被垃圾回收了
引用队列

ReferenceQueue是用来配合引用工作的,没有ReferenceQueue 一样可以运行。ReferenceQueue是专门用来存放引用的, 当软引用,弱引用,虚引用对应的那个对象被回收后的同时,该引用会自动加入到你所定义的ReferenceQueue中。SoftReferenceWeakReferencePhantomReference 都有一个可以传递 ReferenceQueue 的构造器。创建引用的时候,可以指定关联的队列,当 GC 释放对象内存的时候,会将引用加入到引用队列。 如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动,这相当于是一种通知机制。为什么所引用对象还没被回收,虚引用对象已经加入引用队列了,这句话不确定是否正确。当关联的引用队列中有数据的时候,意味着指向的堆内存中的对象被回收。通过这种方式,JVM 允许我们在对象被销毁后,做一些我们自己想做的事情。
软引用和弱引用可以配合也可以不配合引用队列使用

    • 弱引用虚引用所引用的对象被回收以后,会将这些引用放入引用队列中,方便一起回收这些软/弱引用对象
  • 虚引用和终结器引用必须配合引用队列使用
    • 虚引用对象和终结器引用对象在创建时会关联一个引用队列
强软弱虚引用总结

2、垃圾回收算法

比如现在有如下一段内存,看看三种垃圾回收算法的处理方式:

标记-清除(Mark Sweep)

定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,分两个阶段,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间(这里的腾出内存空间并不是将内存空间的字节清0,而是记录下这段内存的起始结束地址并放入空闲地址列表里,下次分配内存的时候,会直接覆盖这段内存)

优点:速度快,清除操作只需要将垃圾对象的起始结束地址放入空闲地址列表里做一个记录就可以。

缺点容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致jvm启动gc,一旦启动gc,我们的应用程序就会暂停,这就导致应用的响应速度变慢

标记-整理(Mark Compact)

定义标记-整理,会将不被GC Root引用的对象回收,清除其占用的内存空间。然后整理剩余的对象,可以有效避免因内存碎片而导致的问题,但是因为整体需要消耗一定的时间,所以效率较低。

优点:没有内存碎片。

缺点:整理阶段涉及到对象的复制移动和引用地址的改变(比如,其他的变量引用了要移动的对象,需要改变该变量的引用地址),效率较低。

标记-复制(Copy)

定义:标记-复制算法,将内存分为等大小的两个区域,FROM和TO(TO中为空)。首先先做一次标记找到那些不被引用的对象,然后将被GC Root引用的对象从FROM放入TO中,再回收不被GC Root引用的对象。然后交换FROM和TO。这样也可以避免内存碎片的问题,但是会占用双倍的内存空间。

优点:不会产生内存碎片。

缺点:需要占用双倍的内存空间,造成空间浪费。如果对象存活率较高时,需要进行较多的复制操作,效率降低。所以JVM大多优先使用这种收集算法去回收新生代。

3、分代回收

定义:分代垃圾回收机制,把整个堆内存分为两块,新生代和老年代。其中新生代又划分为伊甸园、幸存区From和幸存区To。新生代处理的大多是朝生夕死的对象,而老年代处理的是存活时间更长,更有价值的对象。针对不同区域,采取不同的垃圾回收算法,可以更有效的管理内存。

回收流程

新创建的对象都被放在了新生代的伊甸园

当伊甸园中的内存不足时,就会进行一次垃圾回收,这时的回收叫做 Minor GC。Minor GC 会将伊甸园和幸存区FROM存活的对象复制到 幸存区 TO中, 并让其寿命加1,再交换两个幸存区

再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作),这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区TO中。回收以后会交换两个幸存区,并让幸存区中的对象寿命加1

如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会被放入老年代

如果新生代老年代中的内存都满了,就会先触发Minor GC,再触发Full GC,扫描新生代和老年代中所有不再使用的对象并回收。

总结:

  • 对象有限分配在伊甸园区域
  • 新生代空间不足时,触发minor gc,伊甸园和 from存活的对象使用copy复制到to中,存活的对象年龄加 1并且交换 from和to
  • minor gc 会引发 stop the world(暂停其他用户线程,只让垃圾回收线程工作,直到垃圾回收完成,用户线程才会恢复工作。因为这里牵扯到对象的移动,对象的引用地址在发生变化,所以如果不停止用户线程,会发生混乱)。不过由于新生代大部分对象都是朝生夕死的,标记和复制过程是比较短的,STW也是比较短的。
  • 当对象寿命超过阈值(阈值不一定是最大值15)时,会晋升至老年代,最大寿命是15(寿命存放在每个对象的对象头中,占4bit,故而最大就是15)
  • 大对象直接进入老年区,-XX:PretenureSizeThreshold指定大于设置值的对象直接在老年代分配防止大对象在Eden区和两个Survivor区之间来回复制,产生大量的内存复制操作。当老年代空间不足,会先尝试触发 minor gc, 如果之后空间仍不足,那么触发 full gc(Full GC也会触发STW,但是相比于minor GC产生的STW的暂停时间要长。因为老年代大部分是存活对象,并且使用标记-清除和标记-整理算法,STW的暂停时间要更长。)

各个GC定义

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):只目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意”Major GC“这个说法现在有点混淆,在不同资料上常有不同所指,读者需按照上下文区分到底是指老年代的收集还是整堆收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。深入理解jvm里面的概念,个人认为不准确针对老年代的GC行为。

相关VM参数

含义 参数

堆初始大小 -Xms

堆最大大小 -Xmx 等价 -XX:MaxHeapSize=size

新生代大小 -Xmn 等价 (-XX:NewSize=size 加 -XX:MaxNewSize=size)

幸存区比例(动态)-XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy

幸存区比例 -XX:SurvivorRatio=ratio(设置伊甸园空间大小和幸存者空间大小之间的比率。默认情况下,此选项设置为8。例如,新生代一共有10MB,伊甸园占8MB,两个幸存区分别占用1MB)

晋升阈值 -XX:MaxTenuringThreshold=threshold

晋升详情 -XX:+PrintTenuringDistribution

GC详情 -XX:+PrintGCDetails -verbose:gc

FullGC 前 MinorGC -XX:+ScavengeBeforeFullGC(在每次完全GC之前启用年轻一代的GC。默认情况下,此选项处于启用状态。Oracle建议您不要禁用它,因为在Full GC之前清理年轻一代可以减少从老一代空间到年轻一代空间可访问的对象数量,加速Full GC。)

GC 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.ArrayList;

/**
* 演示内存的分配策略
*/
public class Test2 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws InterruptedException {
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.ArrayList;

/**
* 演示内存的分配策略
*/
public class Test2 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws InterruptedException {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;

/**
* 演示内存的分配策略
*/
public class Test2 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws InterruptedException {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
list.add(new byte[_1MB]);
}
}

大对象处理策略

当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.ArrayList;

/**
* 演示内存的分配策略
*/
public class Test2 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws InterruptedException {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;

/**
* 演示内存的分配策略
*/
public class Test2 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws InterruptedException {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}
}

线程内存溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.ArrayList;

/**
* 演示内存的分配策略
*/
public class Test2 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}).start();

System.out.println("sleep....");
Thread.sleep(1000L);
}
}

某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行。这是因为当一个线程抛出OOM异常后它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常

4、垃圾回收器

相关概念

并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

第一类:串行

  • 单线程
  • 堆内存较小,个人电脑(CPU核数较少)

开启串行垃圾回收器 -XX:+UseSerialGC 等于Serial(新生代里面的复制算法)加SerialOld(老年代里的标记-整理算法)

安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象。因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态。

Serial 收集器

Serial收集器是最基本的、发展历史最悠久的收集器

特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本

特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题

Serial Old 收集器

Serial Old是Serial收集器的老年代版本

特点:同样是单线程收集器,采用标记-整理算法

第二类:吞吐量优先

  • 多线程
  • 堆内存较大,多核CPU
  • 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短。吞吐量是指一定时间内回收垃圾的多少。
  • JDK1.8默认使用的垃圾回收器

-XX:+UseParallelGC 和 -XX:+UseParallelOldGC:分别开启新生代和老年代的并行垃圾回收器。JDK1.8默认开启。这两个开关,只要开启任意一个,另外一个也会自动开启。一般都会把CPU里的所有线程都占满,尽快完成垃圾回收。

-XX:+UseAdaptiveSizePolicy 启用自适应新生代调整,比如eden区和两个幸存区的比例,以及晋升老年代的阈值等。

-XX:GCTimeRatio=ratio 调整垃圾回收的时间相对于总时间的占比。ratio默认值99,垃圾回收器占用总时间的比率不能超过1/(1 + radio),如果超过了,那么JVM会动态增大堆内存大小,以减少垃圾回收。

-XX:MaxGCPauseMillis=ms 最大暂停毫秒时间。默认值是200ms。GCTimeRatio和这个参数是相反的,需要折中。因为想要降低垃圾回收的时间占比,需要将堆内存变大,但是堆内存变大,每次暂停时间会变长。

-XX:ParallelGCThreads=n 设置垃圾回收器可以调用的线程数。默认值取决于JVM可用的CPU数量。

Parallel Scavenge 收集器

与吞吐量关系密切,故也称为吞吐量优先收集器

特点:属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与ParNew收集器类似)

该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)

GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。

Parallel Scavenge收集器使用两个参数控制吞吐量:

  • XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小
Parallel Old 收集器

是Parallel Scavenge收集器的老年代版本

特点:多线程,采用标记-整理算法(老年代没有幸存区)

JDK8默认是什么垃圾回收器?

-XX:+PrintCommandLineFlags 参数可查看默认设置收集器类型
-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断

UseParallelGC = Parallel Scavenge + Parallel Old。

新生代(别名) 老年代(别名) JVM参数
Serial (DefNew) Serial Old(PSOldGen或Tenured) -XX:+UseSerialGC
Parallel Scavenge (PSYoungGen) Parallel Old (ParOldGen) -XX:+UseParallelOldGC 或者 -XX:+UseParallelGC
ParNew (ParNew) Serial Old(PSOldGen或Tenured) -XX:-UseParNewGC同时打开ParNew GC和Serial Old JDK9之后不再支持
ParNew (ParNew) CMS+Serial Old(PSOldGen或Tenured) -XX:+UseConcMarkSweepGC
G1 G1 -XX:+UseG1GC

第三类:响应时间优先

  • 多线程
  • 堆内存较大,多核CPU
  • 尽可能让单次STW时间变短(尽量不影响其他线程运行)

-XX:+UseConcMarkSweepGC 打开并发的(垃圾回收线程和用户线程并发)标记-清除垃圾收集器,它工作再老年代(标记-清除算法,适合于长时间存活的区域) 和老年代的CMS垃圾收集器配的是新生代的ParNewGC,ParNewGC采用并发的复制算法,使用-XX:+UseParNewGC打开新生代ParNewGC垃圾收集器。一般CMS和ParNewGC是一对组合,分别工作在老年代和新生代。

对于CMS,如果老年代内存触发的GC的内存占比设置的过大,以致于无法满足浮动垃圾(或者说并发清理过程中新产生的对象),那么会出现并发失败,这时CMS的并发清理就会退化为 SerialOld,一边冻结用户线程的执行,一边SerialOld重新进行老年代的垃圾收集,不过这样STW的时间就十分长了。 但是如果老年代内存触发的GC的内存占比设置的过小,GC的频率会比较高。故而老年代内存触发的GC的内存占比需要折中选择。

-XX:ParallelGCThreads=n 垃圾回收器并行线程数,默认值是JVM可调用CPU数量 ~ -XX:ConcGCThreads=threads 垃圾回收器并发线程数,一般推荐设置为并行线程数的1/4(留出用户线程),默认值是JVM可调用CPU数量

-XX:CMSInitiatingOccupancyFraction=percent 老年代占用百分比(0到100)的阈值,触发CMS。

-XX:+CMSScavengeBeforeRemark 重新标记阶段之前,由于用户线程在并发标记阶段产生了一些新生代里的对象并且引用了老年代里的对象,在重新标记阶段,需要通过新生代的对象到老年代里的对象做一次可达性分析。由于新产生的新生代对象是比较多的,并且很大一部分是朝生夕死的,从新生代到老年代做可达性分析,就算找到了一些老年代,这些新生代有很大一部分也会马上被回收,做了很多无用的查找工作。这个开关就是在重新标记阶段之前,对新生代做一次ParNewGC,先清理一次新生代,减小重新标记的工作量。

CMS 收集器

一种以获取最短回收停顿时间为目标的收集器

特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片

应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务

CMS收集器的运行过程分为下列4步:

  • 初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题(很短暂)

  • 并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行

  • 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题

  • 并发清除:对标记的对象进行清除回收

问题

  • 由于并发清除过程中,其他用户线程仍会不断产生新的垃圾,这些新的垃圾需要等到下一次垃圾回收时才能清理,这些垃圾被称为浮动垃圾,由于CMS在垃圾回收的过程中会产生浮动垃圾,这样导致CMS不能像其他垃圾回收器一样等到堆内存不足了再做垃圾回收。故而需要预留一些来存放浮动垃圾。
  • 由于CMS是标记-清除算法,会导致在老年代产生大量内存碎片。如果发生并发失败,那么CMS会想浮动垃圾过多产生并发失败一样,CMS退化为单线程的SerialOld(标记-整理算法)进行整理碎片,这STW时间会很长。

关于为什么CMS会退化成SerialOld,而不是Parallel Old。我个人认为是,并发失败发生的两个原因来的都比较突然,无法到达Parallel Old要求的安全点,故而只能采用Serial Old彻底阻塞用户线程,进行老年代的标记-整理算法。

CMS收集器的内存回收过程是与用户线程一起并发执行

G1

定义

Garbage First

JDK 9以后默认使用,而且替代了CMS 收集器

适用场景
  • 同时注重吞吐量和低延迟(低响应时间),默认的暂停目标是200ms
  • 超大堆内存(内存大的),会将堆内存划分为多个大小相等的Region区域(每个区域大小在1MB—32MB之间,且应为2的N次幂,每个区域都可以独立的做为Eden、幸存区和老年代。堆内存过大,回收速度会变慢,这种化整为零的思想,可以进行优化,加快标记和拷贝的速度)(当内存较小时,G1和CMS的暂停时间基本一致,但是当内存越大时,G1的暂停时间会比CMS的暂停时间越来越小。G1比CMS更适合大内存的服务器场景。)
  • 整体上是标记-整理算法(避免了CMS的内存碎片问题),两个区域之间是复制算法

相关参数

-XX:MaxGCPauseMillis=time 设置GC暂停目标时间

-XX:+UseG1GC JDK8 并不是默认开启的,所需要参数开启,JDK9默认开启

-XX:G1HeapRegionSize=size 设置每个区域大小在1MB—32MB之间,且应为2的N次幂

G1垃圾回收阶段

第一个阶段:Young Collection

第二个阶段:Young Collection + Concurrent Mark

第三个阶段:Mixed Collection

以上三个阶段循环执行,刚开始是新生代的垃圾收集,如果经过一段时间老年代的内存超过阈值了,那么它会在新生代的垃圾收集的同时进行并发标记,等这个阶段完成后,会进行混合收集,混合收集会对整个新生代和部分老年代都进行一次比较大的收集。如此循环往复。

新生代伊甸园垃圾回收—–>内存不足,新生代回收+并发标记—–>回收新生代伊甸园、幸存区、老年代内存——>新生代伊甸园垃圾回收(重新开始)

Young Collection

分区算法region

分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个区域都可以独立的做为伊甸园、幸存区和老年代,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间。G1的垃圾回收是以区域为单位的。

E:伊甸园 S:幸存区 O:老年代

  • 会STW(新生代垃圾回收的复制算法引起的,时间较短)

刚开始都是伊甸园区,当伊甸园逐渐被占满,触发新生代垃圾回收。

新生代垃圾回收的复制算法,会将存活的对象放入幸存区。

在工作一段时间,当幸存区的存活对象年龄超过阈值或幸存区内存比较满,又会触发新生代的垃圾回收,一部分年龄超过阈值的会晋升到老年代,在幸存区里不够年龄的对象和伊甸园这一轮存活的对象会。复制到另外一块幸存区区域。

Young Collection + CM(Concurrent Mark并发标记)
  • 不会STW
  • 在 Young GC 时会对 GC Root 进行初始标记。就是说在Young GC的STW时,进行初始标记,不会占用并发标记的时间,相当于这个阶段没有STW。

    • 初始标记:对GC Root进行标记
    • 并发标记:顺着GC Root对其引用的对象进行标记
  • 当老年代占用堆内存的比例达到阈值时,对进行并发标记(不会STW),阈值可以根据用户来进行设定

-XX:InitiatingHeapOccupancyPercent=percent 默认45%

Mixed Collection

会对E S O 进行全面的回收

  • 最终标记(Remark)会STW,之前并发标记时,用户线程在工作可能会产生新的垃圾和改变一些对象的引用,造成漏标和错标,所以需要STW,进行最终标记。
  • 拷贝存活(Evacuation)会STW,在不超过暂停时间的前提下,选出一部分价值最高的老年代区域进行回收。

-XX:MaxGCPauseMills:ms 用于指定最长的停顿时间

:为什么有的老年代被拷贝了,有的没拷贝?

因为指定了最大停顿时间,如果对所有老年代都进行回收,老年代中存活的对象很多,有大量的老年代存活对象需要从一个区域复制到另一个区域,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)。因为每次清理块的时间时一样的,所以在用户设置的时间内尽可能回收性价比比较高的块。在垃圾清理时,并不需要一下清理所有的新生代老年代区域,只需要清理哪些需要清理的小块。

Mixed Collection中的新生代垃圾回收:Eden中的存活对象和幸存区中不够晋升年龄的存活对象,复制到另外一块幸存区。幸存区符合晋升年龄的存活对象复制到老年区。

Mixed Collection中的老年代垃圾回收:选出回收价值和暂停时间,选出一批回收价值最高且不超过暂停时间,进行老年代的垃圾回收,将存活的老年代对象复制到另一个区域。

为什么叫Garbage first?

因为要优先回收价值高的区域,目的是达到暂停时间短的目标。

Young Collection 跨代引用
  • 新生代回收的跨代引用(老年代引用新生代)问题。但是老年代存活的对象是比较多的且存活的GC Root也多,这些GC Root引用新生代对象也比较少,如果遍历整个老年代,然后通过可达性分析算法从GC Root找那些新生代对象被引用,这是比较耗时的。

  • 卡表与Remembered Set
    • 脏卡:O被划分为多个区域(一个区域512K),如果该区域有一个老年代的对象引用了新生代对象,则该区域被称为脏卡。这样只需要从脏卡中的GC Root对象开始可达性分析,对新生代对象进行标记,效率比较高。
    • Remembered Set 存在于E中,用于保存新生代对象对应的脏卡。每个Region内都会维护一个,因此G1收集器相对而言比较消耗内存的原因就在这里,它需要约5~10%的空间维护记忆集。这样做新生代垃圾回收时,先通过Remenbered set得到脏卡位置,到这些脏卡遍历GC Root,做可达性分析,提高了效率。
    • Remembered Set保存脏卡,脏卡就是一段含有指向内存地址的数据。
  • 在引用变更时通过post-write barried写屏障 + dirty card queue脏卡队列(通过写屏障,在每次对象的引用发生变更时,都要更新脏卡,这是一个异步操作不会立刻完成脏卡的更新,把更新的指令放入脏卡队列之中,将来脏卡队列size到达阈值之后由一个线程完成脏卡的更新操作)
  • concurrent refinement threads 更新 Remembered Set

Remark(重新标记阶段)

在垃圾回收时,收集器处理对象的过程中。

G1总结

总体上来说,G1垃圾收集器一共有四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并修改TAMS指针的值,让下一个阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • **最终标记(Final Marking):**对用户线程做另外一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • **筛选回收(Live Data Counting and Evacuation):**负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region种,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

三色标记法

  • 黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)
  • 灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)
  • 白色:该对象没有被标记过。(垃圾对象)

下图是并发标记阶段,对象的处理状态:

但是在并发标记过程中,有可能C被处理了(此时没有对象引用C),但该处理过程还未结束,在处理过程结束之前A引用了C,这是C就被漏掉了,这时就会用到remark。

过程如下

  • 之前C未被引用,这时A引用了C,就会给C加一个**pre-write barrier写屏障,写屏障的指令会被执行,将C放入一个satb_mark_queue**队列当中,并将C变为 处理中 状态
  • 并发标记阶段结束以后,重新标记阶段会STW,然后将放在该队列中的对象重新处理,发现有强引用引用它,就会处理它

原理:当对象的引用发生改变时,JVM就会加入一个写屏障(只要对象的引用发生改变,写屏障代码就会被执行),写屏障代码会将该对象加入一个队列当中并且会将该对象改为灰色(正在处理状态)。等到并发标记阶段结束后,进入remark阶段,此时发生STW,此时重新标记线程从队列中逐个取出对象,如果对象是灰色的则需要再做一次可达性分析。

G1从JDK8到JDK9的优化
  • JDK 8u20 字符串去重
  • JDK 8u40 并发标记类卸载
  • JDK 8u60 回收巨型对象
  • JDK 9并发标记起始时间的调整
JDK 8u20版本中 字符串去重

-XX:+UseStringDeduplication 打开字符串char数组去重,默认是打开

1
2
String s1 = new String("hello"); //char[]{'h','e','l','l','o'};
String s2 = new String("hello"); //char[]{'h','e','l','l','o'};

观察上面代码,我们发现,虽然两个字符串的值相同,但是却是两个堆对象,分别指向两个char数组。这种空间浪费的情况可以使用intern()来解决,使其引用串池中共同的一个字符串对象。但是这对于程序员的要求比较高。所以JDK 8u20版本中,优化了这种空间浪费,使得两个堆字符串对象的成员变量char数组引用同一个,减少了空间浪费。

过程

  • 将所有新分配的字符串(底层是char[])放入一个队列
  • 当新生代回收时,G1并发检查是否有重复的字符串(和intern()使用的字符串表不同,个人理解只检查在队列里面的字符串)
  • 如果字符串的值一样,就让他们引用同一个char数组
  • 注意,其与String.intern的区别
    • intern关注的是字符串对象
    • 字符串去重关注的是char[]
    • 在JVM内部,使用了不同的字符串表

优点与缺点

  • 节省了大量内存
  • 新生代回收时间略微增加,导致略微多占用CPU
JDK 8u40 并发标记类卸载

在JDK 8u40的之前版本中,类一般是没办法卸载的,只要加载了之后会一直占用内存。尤其是一些自定义的类加载器创建和加载的类,使用一段时间后就没有人再用了,一直占用内存,对垃圾回收是不利的。

过程:

在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类。一般卸载的是自定义的类,JVM的启动类加载器、扩展类加载器和应用程序类加载器会始终存在,对于自定义的类加载器才会有卸载的需求和功能。

类卸载的条件:

  • 类的实例都被回收掉了
  • 类所在的类加载器的所有类都不在使用了

-XX:+ClassUnloadingWithConcurrentMark 打开并发标记类卸载,默认开启

JDK 8u60 回收巨型对象

其实G1中有四种区域:Eden区、幸存区、老年代区、巨型对象区

巨型对象:

  • 一个对象大于region的一半时,当然也可以占用多个区,就称为巨型对象

  • G1不会对巨型对象进行拷贝,因为拷贝的代价十分大

  • 回收时被优先考虑巨型对象

  • G1会跟踪从老年代到巨型对象所有incoming引用,如果老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉(G1希望巨型对象越早回收越好,最好在新生代垃圾回收时就回收掉)。

    为什么只需要跟踪老年代到巨型对象的引用呢?

    这是因为新生代存活对象少,很快就可以遍历分析可达性完成;而老年代存活对象多,遍历分析可达性代价大,这也是为什么要引用脏卡和记忆表的原因。

JDK9 并发标记起始时间的调整

并发标记必须在堆空间占满前完成,否则退化为FullGC(之前是使用Serial GC清理老年代,现在也变为多线程清理老年代了,但是也不好,因为STW时间长)。故而应该提前开始并发收集老年代。

JDK9之前需要使用-XX:InitiatingHeapOccupancyPercent 设置老年代占比阈值,如果大于这个值,并发的老年代垃圾回收就会开始了。 但是比较麻烦,阈值大了,退化为FullGC的概率大,STW时间长;阈值小了,并发垃圾回收老年代的频率高,同样影响程序。

JDK9可以动态调整了,只用设置一个初始阈值,不需要人为调整。

  • -XX:InitiatingHeapOccupancyPercent 用来设置初始值

  • 进行数据采样并动态调整

  • 总会添加一个安全的空档空间

各种类型GC总结

  • Serial GC
    • 新生代内存不足发生的垃圾收集 minor gc
    • 老年代内存不足发生的垃圾收集 full gc(Major gc)
  • Parallel GC
    • 新生代内存不足发生的垃圾收集 minor gc
    • 老年代内存不足发生的垃圾收集 full gc(Major gc)
  • CMS
    • 新生代内存不足发生的垃圾收集 minor gc
    • 老年代内存不足。如果没有并发失败就继续并发清理,否则Serial GC。
  • G1
    • 新生代内存不足发生的垃圾收集 minor gc
    • 老年代内存不足

G1在老年代内存不足时(老年代所占内存超过阈值)

  • 如果垃圾产生速度慢于垃圾回收速度,不会触发Full GC(老年代进行Serial GC),还是并发地进行清理
  • 如果垃圾产生速度快于垃圾回收速度,便会触发Serial GC(现在的版本,即使是Full GC,也是多线程的老年代回收器,但是也不好,STW的时间长)

5、GC 调优

官方文档

各个JDK版本调优的官方文档

预备知识

  • 掌握GC相关的VM参数,会基本的空间调整

    “F:\JAVA\JDK8.0\bin\java” -XX:+PrintFlagsFinal -version | findstr “GC” 查看与GC相关的参数设置情况,可以根据参数去查询具体的信息。

  • 掌握相关工具

    jmap,jconsole等等工具。

  • 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则

调优领域

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

确定目标

低延迟/高吞吐量? 选择合适的GC

  • CMS(jdk9已经不推荐), G1(jdk9默认,大堆内存下工作比CMS要好,可以通过调参平衡低延迟和高吞吐量目标), ZGC(JDK12体验垃圾回收器,目标是超低延迟) (低延迟)
  • ParallelGC (高吞吐量)
  • Zing(对外宣称几乎零STW,同时可以管理超大堆内存)

最快的GC是不发生GC

首先排除减少因为自身编写的代码而引发的内存问题

  • 查看Full GC前后的内存占用,考虑以下几个问题
    • 数据是不是太多?
      • JDBC中,resultSet = statement.executeQuery(“select * from 大表”)。把表中数据都从mysql加载入堆内存。
    • 数据表示是否太臃肿
      • 对象图
      • 对象大小
    • 是否存在内存泄漏
      • 有一些缓存数据,比如static Map map不断向它put对象,导致堆内存吃紧,不断发生GC。缓存数据不建议使用Java中的实现,因为他们不是专业做缓存的。可以考虑使用第三方缓存实现,如Redis,他们都会考虑缓存数据的过期,Redis自己做内存管理不会对JVM堆内存造成压力。如果不想使用第三方缓存实现,可以使用软、弱引用,及时回收缓存数据。

新生代调优

GC调优一般从新生代调优开始,因为新生代调优优化空间大一些。

  • 新生代的特点
    • 所有的new操作分配内存都是非常廉价的,在Eden区分配内存。
      • TLAB(thread-local allocation buffer),即对于每个线程都会在Eden区给它分配一块私有的区域。当new一个对象时,会优先检查TLAB是否有可用内存,如果有则优先在这块内存进行分配,保证线程并发安全保护。
    • 死亡对象回收零代价。因为新生代采用标记-复制算法,故而死亡对象回收代价为零。
    • 大部分新生代对象用过即死(朝生夕死)
    • 由于使用标记-复制算法且大部分新生代对象朝生夕死,MInor GC 所用时间远小于Full GC
  • 新生代内存越大越好么?
    • 不是,Oracle推荐新生代内存占用堆内存的四分之一到二分之一。
      • 新生代内存太小:频繁触发Minor GC,会STW,会使得吞吐量下降
      • 新生代内存太大:老年代内存占比有所降低,会更频繁地触发Full GC,Full GC的STW要比新生代时间长得多,因为老年代存活对象多,且采用一般需要采用标记-整理算法。而且触发Minor GC时,清理新生代所花费的时间会更长。
    • 新生代内存设置为内容纳[并发量*(请求到响应过程中产生对象的总内存)]的数据为宜。因为这段时间后,这一段时间产生的大部分对象会被回收,只要新生代内存大于[并发量*(请求到响应过程中产生对象的总内存)],就可以较少的触发Minor GC,保证并发量。

幸存区调优

  • 幸存区需要能够保存 当前活跃对象(正在使用,可能下次Minor GC会回收)+需要晋升的对象(年龄不够,但是是存活时间较长的对象,将来会晋升到老年代)

  • 晋升阈值配置得当。一方面希望存活时间短的新生代对象留在新生代中,以便下次Minor GC能够回收掉,不想让它晋升到老年代,如果晋升到老年代,需要等到下次Full GC才能回收,占用空间。另一方面希望让长时间存活的对象尽快晋升,不要在Minor GC过程中每次都要在From 和 To区之间来回复制。

    -XX:MaxTenuringThreshold=threshold 设置晋升阈值

    -XX:+PrintTenuringDistribution 打印幸存区各年龄段内存信息

老年代调优

以 CMS 为例

  • CMS 的老年代内存越大越好

  • 先尝试不做调优,如果没有Full GC那么已经很ok。即使发生了Full GC,也应该先尝试调优新生代。

  • 观察发生Full GC时老年代内存占用,将老年代内存预设调大1/4~1/3 ,减少Full GC的发生频率。

    -XX:CMSInitiatingOccupancyFraction=percent 设置老年代内存占比触发CMS垃圾回收器的比率,若过大,可能会导致在老年代垃圾回收过程中产生过多浮动垃圾,使得CMS垃圾回收器在老年代上从并发垃圾回收变为Serial Old垃圾回收,大幅增大STW时间。一般来说,该值设置为0.75 ~ 0.80,剩下的空间预留给浮动垃圾。

案例

  • 案例1 Full GC和 Minor GC频繁

    首先,Minor GC和Full GC都频繁触发,说明空间不足。排除代码写的有比较严重的内存浪费的情况,有一种情况就是,当业务高峰期时,又使用的是新生代和老年代的并行垃圾回收器,那么打开自适应新生代调整。当大量的对象在新生代被创建,幸存区内存紧张,不仅频繁触发Minor GC,晋升老年代的阈值下调,使得大量朝生夕死的新生代对象晋升到老年代,使得老年代内存也紧张,进而频繁触发Full GC。

    解决方法:增大新生代空间,幸存区内存紧张得到缓解,不会频繁触发Minor GC。同时,晋升老年代的阈值也会自适应提高,不会使得大量朝生夕死对象进入老年代,Full GC也不会频繁被触发。

    -XX:+UseParallelGC 和 -XX:+UseParallelOldGC:分别开启新生代和老年代的并行垃圾回收器。JDK1.8默认开启。这两个开关,只要开启任意一个,另外一个也会自动开启。一般都会把CPU里的所有线程都占满,尽快完成垃圾回收。

    -XX:+UseAdaptiveSizePolicy 启用自适应新生代调整,比如eden区和两个幸存区的比例,以及晋升老年代的阈值等。

  • 案例2 请求高峰期发生Full GC,单次暂停时间特别长(采用CMS垃圾回收器)

    首先,应当查看GC日志,查看究竟是CMS垃圾回收器哪个阶段耗时比较长。

    • 如果是重新标记时间过长,那么应当打开-XX:+CMSScavengeBeforeRemark,重新标记阶段之前做一次新生代的垃圾回收ParNewGC,因为新生代对象是比较多的,很大一部分是朝生夕死的。重新标记阶段需要从新生代对象到老年代对象做一次可达性分析,从新生代到老年代做可达性分析,就算找到了一些老年代,这些新生代有很大一部分也会马上被回收,做了很多无用的查找工作。这个开关就是在重新标记阶段之前,对新生代做一次ParNewGC,先清理一次新生代,减小重新标记的工作量。
    • 如果是并发清理阶段,退化为了Serial Old。那么是因为CMS启动时间过晚,并发清理阶段产生浮动垃圾的速度大于垃圾清理速度,使得程序无法到达安全点,只能采用Serial Old清理老年代垃圾,需要适当设置-XX:CMSInitiatingOccupancyFraction=percent,提前触发老年代垃圾回收。
  • 案例3 老年代充裕情况下,发生Full GC (CMS jdk1.7)

    查看GC日志,没有并发失败和碎片过多产生的提示,说明老年代空间充裕。

    发生这种情况的原因是,在JDK7以及之前,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。故而永久代内存紧张也会触发Full GC,所以需要增大永久代的内存大小。JDK8以及之后,方法区移到了元空间(位于操作系统物理内存,这个比较充裕)。

四、类加载与字节码技术

1、类文件结构

一个简单的HelloWorld.java

1
2
3
4
5
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}

首先获得.class字节码文件

方法:

  • 在文件对应目录下,执行javac -parameters -d . HelloWorld.java (-parameters 生成元数据以用于方法参数的反射)
  • 然后使用od命令,查看二进制字节码文件, od -t xC HelloWorld.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
[root@localhost ~]# od -t xC HelloWorld.class
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07
0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63
0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01
0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63
0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f
0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16
0000220 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72
0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13
0000260 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61
0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46
0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64
0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e
0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64
0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74
0000440 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c
0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61
0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61
0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f
0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72
0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76
0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d
0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a
0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01
0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00
0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00
0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00
0001000 0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00
0001020 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a
0001040 00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b
0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00
0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00
0001120 00 00 02 00 14

根据 JVM 规范,类文件结构如下

第一列是字节数,前四个字节是魔数,再接下来两个字节是小版本号,再接下来两个字节是主版本号。再接下来的constant_pool_countconstant_pool[constant_pool_count-1]是常量池的信息。access_flags是访问标志,这个Class是一个类还是接口,这个类是不是公共的等等,this_class自己的包名和类名信息,super_class父类的信息,interfaces_countinterfaces[interfaces_count]是该类的接口信息,fields_countfields[fields_count]该类中成员变量和静态变量信息,methods_countmethods[methods_count]该类中成员方法和静态方法的信息,attributes_countattributes[attributes_count]类的一些附加的属性信息。

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];
}

魔数

u4 magic,字节码文件的0-3字节,表示它是否是[class]类型的文件.(魔术就是来标示改文件的类型,是.class文件还是.jpg文件等等)

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

版本

字节码文件的4-7字节,表示类的版本 00 34(52)表示是 Java 8

u2 minor_version;

u2 major_version;

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

34H = 52,代表JDK8

常量池

字节码文件的8-9字节,表示常量池长度,00 23(35)表示常量池有#1~#34项,注意#0项不计入,也没有值

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

第#1项0a通过查上述表可以得知表示一个 Method信息,00 0600 15 (21)表示它引用了常量池中 #6 和 #21项来获得这个方法的【所属类】和【方法名】

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

第2项09通过查上述表可以得知表示一个 Field信息,00 16(22)00 17(23) 表示它引用了常量池中#22和#23项来获得这个成员变量的【所属类】和【成员变量名】

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

0000020 00 16 00 17 08 00 18 0a 00 19 00 la 07 00 1b 07

第#3项 08 表示一个字符串常量名称,**00 18(24)**表示它引用了常量池中 #24 项

0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07

第#4项 0a 表示一个 Method 信息,00 19(25)00 1a(26) 表示它引用了常量池中 #25 和 #26 项来获得这个方法的【所属类】和【方法名】

0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07

第#5项 07 表示一个 Class 信息,00 1b(27) 表示它引用了常量池中 #27 项

0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07

第#6项 07 表示一个 Class 信息,00 1c(28) 表示它引用了常量池中 #28 项

0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07
0000040 00 1c
01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29

第#7项 01 表示一个 utf8 串,00 06 表示长度,3c 69 6e 69 74 3e 是【 代表构造方法

0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29

第#8项 01 表示一个 utf8 串,00 03 表示长度,28 29 56 是【()V】其实就是表示无参、无返回值

0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e

第#9项 01 表示一个 utf8 串,00 04 表示长度,43 6f 64 65 是【Code】

0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e

第#10项 01 表示一个 utf8 串,00 0f(15) 表示长度,4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 是【LineNumberTable】

0000060 56 01 00 04 43 6f 64 65 **01 00 0f 4c 69 6e 65 4e **

0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63

第#11项 01 表示一个 utf8 串,00 12(18) 表示长度,4c 6f 63 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65是【LocalVariableTable】

0000100 75 6d 62 65 72 54 61 62 6c 65 **01 00 12 4c 6f 63 **

0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01

第#12项 01 表示一个 utf8 串,00 04 表示长度,74 68 69 73 是【this】

0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01
0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63

第#13项 01 表示一个 utf8 串,00 1d(29) 表示长度,4c 63 6e 2f 69 74 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f 57 6f 72 6c 64 3b是【Lcn/itcast/jvm/t5/HelloWorld;】 在字节码文件中表示类型,引用类型是L开头,分号结尾,中间是该类型的全路径

0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63

0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f

0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16

第#14项 01 表示一个 utf8 串,00 04 表示长度,74 68 69 75 是【main】

0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16

第#15项 01 表示一个 utf8 串,00 16(22) 表示长度,是【([Ljava/lang/String;)V】其实就是参数为字符串数组,无返回值在字节码文件中,类型前面加[说明是一个数组

0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e **01 00 16 **

0000220 **28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 **

0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13

第#16项 01 表示一个 utf8 串,00 04 表示长度,是【args】

0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13

第#17项 01 表示一个 utf8 串,00 13(19) 表示长度,是【[Ljava/lang/String;】

0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 **01 00 13 **

0000260 **5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 **

0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61

第#18项 01 表示一个 utf8 串,00 10(16) 表示长度,是【MethodParameters】

0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61
0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46

第#19项 01 表示一个 utf8 串,00 0a(10) 表示长度,是【SourceFile】

0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46
0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64

第#20项 01 表示一个 utf8 串,00 0f(15) 表示长度,是【HelloWorld.java】

0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64

0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e

第#21项 0c 表示一个 【名+类型】,00 07 00 08 引用了常量池中 #7 #8 两项

0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e

第#22项 07 表示一个 Class 信息,00 1d(29) 引用了常量池中

#29 项 0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e

第#23项 0c 表示一个 【名+类型】,00 1e(30) 00 1f (31)引用了常量池中 #30 #31 两项

0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e
0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64

第#24项 01 表示一个 utf8 串,00 0b(11) 表示长度,是【hello world】

0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64

第#25项 07 表示一个 Class 信息,00 20(32) 引用了常量池中 #32 项

0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74

第#26项 0c 表示一个 【名+类型】,00 21(33) 00 22(34)引用了常量池中 #33 #34 两项

0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74

第#27项 01 表示一个 utf8 串,00 1b(27) 表示长度,是【cn/itcast/jvm/t5/HelloWorld】

0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74
000044063 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c
0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61

第#28项 01 表示一个 utf8 串,00 10(16) 表示长度,是【java/lang/Object】

0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61
0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61

第#29项 01 表示一个 utf8 串,00 10(16) 表示长度,是【java/lang/System】

0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61
0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d
01 00 03 6f

第#30项 01 表示一个 utf8 串,00 03 表示长度,是【out】

0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 **00 03 6f **

0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72

第#31项 01 表示一个 utf8 串,00 15(21) 表示长度,是【Ljava/io/PrintStream;】

0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72
0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76

第#32项 01 表示一个 utf8 串,00 13(19) 表示长度,是【java/io/PrintStream】

0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76
0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d

第#33项 01 表示一个 utf8 串,00 07 表示长度,是【println】

0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a

第#34项 01 表示一个 utf8 串,00 15(21) 表示长度,是【(Ljava/lang/String;)V】

0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a
0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01

访问标识与继承信息

21 表示该 class 是一个类,公共的(0x0001 + 0x0010)
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 05

表示根据常量池中 #5 找到本类全限定名
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 06

表示根据常量池中 #6 找到父类全限定名
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01

表示接口的数量,本类为 0
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01

Flag Name Value Interpretation
ACC_PUBLIC 0x0001 Declared public ; may be accessed from outside its package.
ACC_STATIC 0x0008 字段是否static
ACC_FINAL 0x0010 Declared final ; no subclasses allowed.
ACC_SUPER 0x0020 Treat superclass methods specially when invoked by the invokespecial instruction.表示一个类
ACC_INTERFACE 0x0200 Is an interface, not a class.
ACC_ABSTRACT 0x0400 Declared abstract ; must not be instantiated.
ACC_SYNTHETIC 0x1000 Declared synthetic; not present in the source code.人工合成的,不是源代码的
ACC_ANNOTATION 0x2000 Declared as an annotation type.表示注解
ACC_ENUM 0x4000 Declared as an enum type.表示枚举

Field 信息

表示成员变量数量,本类为 0
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01

FieldType Type Interpretation
B byte signed byte
C char Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16
D double double-precision floating-point value
F float single-precision floating-point value
I int integer
J long long integer
LClassName; reference an instance of class ClassName
S short signed short
Z boolean true or false
[ reference one array dimension

Method 信息

表示方法数量,本类为 2(构造方法和main方法)

0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01

构造方法

一个方法由 访问修饰符,名称,参数描述,方法属性数量,方法属性组成

  • 00 01代表访问修饰符(本类中是 public)

  • 00 07代表引用了常量池 #07 项作为方法名称

  • 00 08代表引用了常量池 #08 项作为方法参数描述
    00 01黄色代表方法属性数量,本方法是 1

  • 剩下的代表方法属性

    • 00 09 表示引用了常量池 #09 项,发现是【Code】属性
    • 00 00 00 2f 表示此属性的长度是 47
    • 00 01 表示【操作数栈】最大深度
    • 00 01 表示【局部变量表】最大槽(slot)数
    • 00 00 00 05 表示字节码长度,本例是 5
    • 2a b7 00 01 b1 是字节码指令
    • 00 00 00 02 表示方法细节属性数量,本例是 2
    • 00 0a 表示引用了常量池 #10 项,发现是【LineNumberTable】属性,将字节码文件行号和Java源码行号进行对应,方便Debug调试使用
      • 00 00 00 06 表示此属性的总长度,本例是 6

      • 00 01 表示【LineNumberTable】长度

      • 00 00 表示【字节码】行号 00 04 表示【java 源码】行号

    • 00 0b 表示引用了常量池 #11 项,发现是【LocalVariableTable】属性,局部变量表
      • 00 00 00 0c 表示此属性的总长度,本例是 12
        • 00 01 表示【LocalVariableTable】长度
        • 00 00 表示局部变量生命周期开始,相对于字节码的偏移量
        • 00 05 表示局部变量覆盖的范围长度
        • 00 0c 表示局部变量名称,本例引用了常量池 #12 项,是【this】 00 0d 表示局部变量的类型,本例引用了常量池 #13 项,是 【Lcn/itcast/jvm/t5/HelloWorld;】
        • 00 00 表示局部变量占有的槽位(slot)编号,本例是 0

0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01

0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01

0000720 **00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00 **

0000740 **00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00 **

0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00

main方法
  • 00 09代表访问修饰符(本类中是 public static)
  • 00 03代表引用了常量池 #14 项作为方法名称
  • 00 0f代表引用了常量池 #15 项作为方法参数描述
  • 00 02代表方法属性数量,本方法是 2
  • 其余黑体代表方法属性(属性1)
    • 00 09 表示引用了常量池 #09 项,发现是【Code】属性
    • 00 00 00 37 表示此属性的长度是 55
    • 00 02 表示【操作数栈】最大深度
    • 00 01 表示【局部变量表】最大槽(slot)数
    • 00 00 00 05 表示字节码长度,本例是 9
    • b2 00 02 12 03 b6 00 04 b1 是字节码指令
    • 00 00 00 02 表示方法细节属性数量,本例是 2
    • 00 0a 表示引用了常量池 #10 项,发现是【LineNumberTable】属性
      • 00 00 00 0a 表示此属性的总长度,本例是 10
      • 00 02 表示【LineNumberTable】长度
      • 00 00 表示【字节码】行号 00 06 表示【java 源码】行号
      • 00 08 表示【字节码】行号 00 07 表示【java 源码】行号
    • 00 0b 表示引用了常量池 #11 项,发现是【LocalVariableTable】属性
      • 00 00 00 0c 表示此属性的总长度,本例是 12
      • 00 01 表示【LocalVariableTable】长度
      • 00 00 表示局部变量生命周期开始,相对于字节码的偏移量
      • 00 09 表示局部变量覆盖的范围长度
      • 00 10 表示局部变量名称,本例引用了常量池 #16 项,是【args】
      • 00 11 表示局部变量的类型,本例引用了常量池 #17 项,是【[Ljava/lang/String;】
      • 00 00 表示局部变量占有的槽位(slot)编号,本例是 0

0000760 01 00 00 00 05 00 0c 00 0d 00 00 **00 09 00 0e 00 **

0001000 **0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00 **

0001020 **09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a **

0001040 **00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b **

0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00

黑体代表方法属性(属性2)

  • 00 12 表示引用了常量池 #18 项,发现是【MethodParameters】属性
    • 00 00 00 05 表示此属性的总长度,本例是 5
    • 01 参数数量
    • 00 10 表示引用了常量池 #16 项,是【args】
    • 00 00 访问修饰符

0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00

0001120 00 00 02 00 14

附加属性

  • 00 01 表示附加属性数量
  • 00 13 表示引用了常量池 #19 项,即【SourceFile】
  • 00 00 00 02 表示此属性的长度
  • 00 14 表示引用了常量池 #20 项,即【HelloWorld.java】

0001100 00 12 00 00 00 05 01 00 10 00 00 **00 01 00 13 00 **

0001120 00 00 02 00 14

参考文献

oracle官方文档

2、字节码指令

入门

接着上一节,研究一下两组字节码指令,一个是public cn.itcast.jvm.t5.HelloWorld();构造方法的字节码指令

1
2a b7 00 01 b1
  1. 2a => aload_0 加载 slot 0 的局部变量,即 this,做为下面的 invokespecial 构造方法调用的对象
  2. b7 => invokespecial 预备调用构造方法,哪个方法呢?
  3. 00 01 引用常量池中 #1 项,即【 Method java/lang/Object.”“:()V 】
  4. b1 => return 表示返回

另一个是 public static void main(java.lang.String[]); 主方法的字节码指令

1
b2 00 02 12 03 b6 00 04 b1
  1. b2 => getstatic 用来加载静态变量,哪个静态变量呢?
  2. 00 02 引用常量池中 #2 项,即【Field java/lang/System.out:Ljava/io/PrintStream;】
  3. 12 => ldc 加载参数,哪个参数呢?
  4. 03 引用常量池中 #3 项,即 【String hello world】
  5. b6 => invokevirtual 预备调用成员方法,哪个方法呢?
  6. 00 04 引用常量池中 #4 项,即【Method java/io/PrintStream.println:(Ljava/lang/String;)V】
  7. b1 => return 表示返回

参考文献

oracle官方文档

javap工具

自己分析类文件结构太麻烦了,Oracle 提供了 javap 工具来反编译 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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
[root@localhost ~]# javap -v HelloWorld.class
Classfile /root/HelloWorld.class
Last modified Jul 7, 2019; size 597 bytes
MD5 checksum 361dca1c3f4ae38644a9cd5060ac6dbc //MD5检验签名
Compiled from "HelloWorld.java"
public class cn.itcast.jvm.t5.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #22.#23 //java/lang/System.out:Ljava/io/PrintStream;
#3 = String #24 // hello world
#4 = Methodref #25.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #27 // cn/itcast/jvm/t5/HelloWorld
#6 = Class #28 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcn/itcast/jvm/t5/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 MethodParameters
#19 = Utf8 SourceFile
#20 = Utf8 HelloWorld.java
#21 = NameAndType #7:#8 // "<init>":()V
#22 = Class #29 // java/lang/System
#23 = NameAndType #30:#31 // out:Ljava/io/PrintStream;
#24 = Utf8 hello world
#25 = Class #32 // java/io/PrintStream
#26 = NameAndType #33:#34 // println:(Ljava/lang/String;)V
#27 = Utf8 cn/itcast/jvm/t5/HelloWorld
#28 = Utf8 java/lang/Object
#29 = Utf8 java/lang/System
#30 = Utf8 out
#31 = Utf8 Ljava/io/PrintStream;
#32 = Utf8 java/io/PrintStream
#33 = Utf8 println
#34 = Utf8 (Ljava/lang/String;)V
{
public cn.itcast.jvm.t5.HelloWorld();
descriptor: ()V
flags: 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 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t5/HelloWorld;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 //String java/io/PrintStream.println:(Ljava/lang/String;)V
5: invokevirtual #4 //Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
MethodParameters:
Name Flags
args
}

图解方法执行流程

1)原始Java代码

1
2
3
4
5
6
7
8
9
10
11
12
package cn.itcast.jvm.t3.bytecode;
/**
* 演示 字节码指令 和 操作数栈、常量池的关系
*/
public class Demo3_1 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}

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
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
[root@localhost ~]# javap -v Demo3_1.class
Classfile /root/Demo3_1.class
Last modified Jul 7, 2019; size 665 bytes
MD5 checksum a2c29a22421e218d4924d31e6990cfc5
Compiled from "Demo3_1.java"
public class cn.itcast.jvm.t3.bytecode.Demo3_1
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#26 // java/lang/Object."<init>":()V
#2 = Class #27 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #28.#29 //java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#6 = Class #32 // cn/itcast/jvm/t3/bytecode/Demo3_1
#7 = Class #33 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcn/itcast/jvm/t3/bytecode/Demo3_1;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 a
#20 = Utf8 I
#21 = Utf8 b
#22 = Utf8 c
#23 = Utf8 MethodParameters
#24 = Utf8 SourceFile
#25 = Utf8 Demo3_1.java
#26 = NameAndType #8:#9 // "<init>":()V
#27 = Utf8 java/lang/Short
#28 = Class #34 // java/lang/System
#29 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 cn/itcast/jvm/t3/bytecode/Demo3_1
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
public cn.itcast.jvm.t3.bytecode.Demo3_1();
descriptor: ()V
flags: 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 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t3/bytecode/Demo3_1;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 //int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 //Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 //Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 8: 0
line 9: 3
line 10: 6
line 11: 10
line 12: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
MethodParameters:
Name Flags
args
}

3)常量池载入运行时常量池

常量池也属于方法区,只不过这里单独提出来了

4)方法字节码载入方法区,main线程开始运行,分配栈帧内存

(stack=2,locals=4) 对应操作数栈有2个空间(每个空间4个字节),局部变量表中有4个槽位

5)执行引擎开始执行字节码

bipush 10

  • 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
    • sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
    • ldc 将一个 int 压入操作数栈
    • ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
    • 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池

istore 1

将操作数栈栈顶元素弹出,放入局部变量表的slot 1中

bipush 10istore 1对应Java代码中的

1
a = 10

ldc #3

读取运行时常量池中#3,即32768(超过short最大值范围的数会被放到运行时常量池中),将其加载到操作数栈中

注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的,此操作是编译优化,叫做常量折叠。

istore 2

将操作数栈中的元素弹出,放到局部变量表的2号位置

iload1 iload2

将局部变量表中1号位置和2号位置的元素放入操作数栈中

  • 因为只能在操作数栈中执行运算操作

iadd

将操作数栈中的两个元素弹出栈并相加,结果在压入操作数栈中

istore 3

将操作数栈中的元素弹出,放入局部变量表的3号位置

getstatic #4

在运行时常量池中找到#4,发现是一个对象

在堆内存中找到该对象,并将其引用放入操作数栈中

iload 3

将局部变量表中3号位置的元素压入操作数栈中

invokevirtual 5

  • 找到常量池 #5 项,定位到方法区 java/io/PrintStream.println:(I)V 方法

  • 生成新的栈帧(分配 locals、stack等)

  • 传递参数,执行新栈帧中的字节码

  • 执行完毕,弹出栈帧

  • 清除 main 操作数栈内容

return

  • 完成 main 方法调用,弹出 main 栈帧

  • 程序结束

联系 - 分析i++

目的:从字节码角度分析 a++ 相关题目

源码:

1
2
3
4
5
6
7
8
9
10
11
package cn.itcast.jvm.t3.bytecode;
/**
* 从字节码角度分析 a++ 相关题目 */
public class Demo3_2 {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--;
System.out.println(a);
System.out.println(b);
}
}

字节码:

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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: iinc 1,1
7: iinc 1,1
10: iload_1
11: iadd
12: iload_1
13: iinc 1,-1
16: iadd
17: istore_2
18: getstatic #2 //Field java/lang/System.out:Ljava/io/PrintStream;
21: iload_1
22: invokevirtual #3 //Method java/io/PrintStream.println:(I)V
25: getstatic #2 //Field java/lang/System.out:Ljava/io/PrintStream;
28: iload_2
29: invokevirtual #3 //Method java/io/PrintStream.println:(I)V
32: return
LineNumberTable:
line 8: 0
line 9: 3
line 10: 18
line 11: 25
line 12: 32
LocalVariableTable:
Start Length Slot Name Signature
0 33 0 args [Ljava/lang/String;
3 30 1 a I
18 15 2 b I

分析:

  • 注意 iinc 指令是直接在局部变量 slot 上进行运算
  • a++ 和 ++a 的区别是先执行 iload 还是 先执行 iinc

条件判断指令

几点说明:

  • byte,short,char 都会按 int 比较,因为操作数栈都是 4 字节
  • goto 用来进行跳转到指定行号的字节码

源码:

1
2
3
4
5
6
7
8
9
10
public class Demo3_3 {
public static void main(String[] args) {
int a = 0;
if(a == 0) {
a = 10;
} else {
a = 20;
}
}
}

字节码:

1
2
3
4
5
6
7
8
9
10
 0: iconst_0      //从0-5这种比较小的数,用iconst_x来表示的
1: istore_1
2: iload_1
3: ifne 12 //如果不等于成立,跳转到12行
6: bipush 10
8: istore_1
9: goto 15
12: bipush 20
14: istore_1
15: return

思考

细心的同学应当注意到,以上比较指令中没有 long,float,double 的比较,那么它们要比较怎 么办?

参考 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.lcmp

循环控制指令

其实循环控制还是前面介绍的那些指令,例如 while 循环:

1
2
3
4
5
6
7
8
public class Demo3_4 {
public static void main(String[] args) {
int a = 0;
while (a < 10) {
a++;
}
}
}

字节码:

1
2
3
4
5
6
7
8
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14 //如果大于等于成立,跳转到14行
8: iinc 1, 1
11: goto 2
14: return

再比如 do while 循环:

1
2
3
4
5
6
7
8
public class Demo3_5 {
public static void main(String[] args) {
int a = 0;
do {
a++;
} while (a < 10);
}
}

字节码是:

1
2
3
4
5
6
7
0: iconst_0 
1: istore_1
2: iinc 1,1
5: iload_1
6: bipush 10
8: if_icmplt 2 //如果小于成立,跳转到2行
11: return

最后再看看 for 循环:

1
2
3
4
5
6
public class Demo3_6 {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
}
}
}

字节码是:

1
2
3
4
5
6
7
8
 0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1,1
11: goto 2
14: return

注意

比较 while 和 for 的字节码,你发现它们是一模一样的,殊途也能同归😊

通过字节码指令来分析问题

Java源码:

1
2
3
4
5
6
7
8
9
10
11
public class Demo2 {
public static void main(String[] args) {
int i=0;
int x=0;
while(i<10) {
x = x++;
i++;
}
System.out.println(x); //结果为0
}
}

为什么最终的x结果为0呢? 通过分析字节码指令即可知晓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Code:
stack=2, locals=3, args_size=1 //操作数栈分配2个空间,局部变量表分配3个空间
0: iconst_0 //准备一个常数0
1: istore_1 //将常数0放入局部变量表的1号槽位 i=0
2: iconst_0 //准备一个常数0
3: istore_2 //将常数0放入局部变量的2号槽位 x=0
4: iload_1 //将局部变量表1号槽位的数放入操作数栈中
5: bipush 10 //将数字10放入操作数栈中,此时操作数栈中有2个数
7: if_icmpge 21 //比较操作数栈中的两个数,如果下面的数大于上面的数,就跳转到21。这里的比较是将两个数做减法。因为涉及运算操作,所以会将两个数弹出操作数栈来进行运算。运算结束后操作数栈为空
10: iload_2 //将局部变量2号槽位的数放入操作数栈中,放入的值是0
11: iinc 2, 1 //将局部变量2号槽位的数加1,自增后,槽位中的值为1
14: istore_2 //将操作数栈中的数放入到局部变量表的2号槽位,2号槽位的值又变为了0
15: iinc 1, 1 //1号槽位的值自增1
18: goto 4 //跳转到第4条指令
21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_2
25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
28: return

构造方法

<cinit>()V
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo3 {

static int i = 10;

static {
i = 20;
}

static {
i = 30;
}

public static void main(String[] args) {
System.out.println(i); //结果为30
}
}

编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法 <cinit()>V

1
2
3
4
5
6
7
8
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #3 // Field i:I
5: bipush 20
7: putstatic #3 // Field i:I
10: bipush 30
12: putstatic #3 // Field i:I
15: return

<cinit>()V 方法会在类加载的初始化阶段被调用

<init>()V
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Demo4 {
private String a = "s1";

{
b = 20;
}

private int b = 10;

{
a = "s2";
}

public Demo4(String a, int b) {
this.a = a;
this.b = b;
}

public static void main(String[] args) {
Demo4 d = new Demo4("s3", 30);
System.out.println(d.a);
System.out.println(d.b);
}
}

编译器会按从上至下的顺序,收集所有初始化代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在后

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
public cn.itcast.jvm.t3.bytecode.Demo3_8_2(java.lang.String, int);
descriptor: (Ljava/lang/String;I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_0
1: invokespecial #1 // super.<init>()V
4: aload_0
5: ldc #2 // <- "s1"
7: putfield #3 // -> this.a
10: aload_0
11: bipush 20 //<-20
13: putfield #4 // -> this.b
16: aload_0
17: bipush 10 //<-10
19: putfield #4 //-> this.b
22: aload_0
23: ldc #5 //<- "s2"
25: putfield #3 //-> this.a
28: aload_0 // ------------------------------
29: aload_1 // <- slot 1(a) "s3" |
30: putfield // -> this.a |
33: aload_0 |
34: iload_2 // <- slot 2(b) 30 |
35: putfield // -> this.b --------------------
38: return
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 39 0 this Lcn/itcast/jvm/t3/bytecode/Demo3_8_2;
0 39 1 a Ljava/lang/String;
0 39 2 b I
MethodParameters: ...

1、aload

从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶

aload_0把this装载到了操作数栈中aload_0是一组格式为aload_的操作码中的一个,这一组操作码把对象的引用装载到操作数栈中标志了待处理的局部变量表中的位置,但取值仅可为0、1、2或者3。

2、iload_,lload_,fload_,dload_

还有一些其他相似的操作码用来装载非对象引用,包括iload_、lload_、fload_和dload_,这里的i代表int型,l代表long型,f代表float型以及d代表double型。在局部变量表中的索引位置大于3的变量的装载可以使用iload、lload、fload,、dload和aload,这些操作码都需要一个操作数的参数,用于确认需要装载的局部变量的位置。

方法调用

看一下几种不同的方法调用对应的字节码指令

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 Demo5 {
public Demo5() {

}

private void test1() {

}

private final void test2() {

}

public void test3() {

}

public static void test4() {

}

public static void main(String[] args) {
Demo5 demo5 = new Demo5();
demo5.test1();
demo5.test2();
demo5.test3();
Demo5.test4();
demo5.test4();
}
}

不同方法在调用时,对应的虚拟机指令有所区别

  • 私有、构造、被final修饰的方法,在调用时都使用invokespecial指令
  • 普通成员方法在调用时,使用invokevirtual指令。因为编译期间无法确定该方法的内容,只有在运行期间才能确定,即为动态绑定。
  • 静态方法在调用时使用invokestatic指令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/nyima/JVM/day5/Demo5
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: invokestatic #7 // Method test4:()V
21: aload_1
22: pop
25: invokestatic #7 // Method test4:()V
28: return
  • new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入操作数栈
  • dup 是赋值操作数栈栈顶的内容,本例即为【对象引用】,为什么需要两份引用呢,一个是要配合 invokespecial 调用该对象的构造方法 “init”:()V (会消耗掉栈顶一个引用),另一个要 配合 astore_1 赋值给局部变量
  • 终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定。只需要查找一次就可以找到方法地址。
  • 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态。可能需要查找多次才能找到方法地址。
  • 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
  • 比较有意思的是 demo.test4(); 是通过【对象引用】调用一个静态方法,可以看到在调用 invokestatic 之前执行了 pop 指令,把【对象引用】从操作数栈弹掉了。所以尽量使用类名来调用静态方法,否则,会产生 aloadpop这两条无用指令。
  • 还有一个执行 invokespecial 的情况是通过 super 调用父类方法

多态原理

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
package cn.itcast.jvm.t3.bytecode;
import java.io.IOException;
/**
* 演示多态原理,注意加上下面的 JVM 参数,禁用指针压缩
* -XX:-UseCompressedOops -XX:-UseCompressedClassPointers */
public class Demo3_10 {
public static void test(Animal animal) {
animal.eat();
System.out.println(animal.toString());
}
public static void main(String[] args) throws IOException {
test(new Cat());
test(new Dog());
System.in.read();
}
}
abstract class Animal {
public abstract void eat();
@Override
public String toString() {
return "我是" + this.getClass().getSimpleName();
}
}

class Dog extends Animal {
@Override
public void eat() { System.out.println("啃骨头");
}
}

class Cat extends Animal {
@Override
public void eat() {
System.out.println("吃鱼");
}
}

1)运行代码
停在 System.in.read() 方法上,这时运行 jps 获取进程 id

2)运行HSDB工具
进入 JDK 安装目录,执行

1
java -cp ./lib/sa-jdi.jar sun.jvm.hotspot.HSDB

进入图形界面 attach to HotSpot process输入进程 id

3)查找某个对象

打开 Tools -> Find Object By Query
输入 select d from cn.itcast.jvm.t3.bytecode.Dog d 点击 Execute 执行。输入全路径类名即可查询,该类对象地址

4)查看对象内存结构

点击超链接可以看到对象的内存结构,此对象没有任何属性,因此只有对象头的 16 字节,前 8 字节是MarkWord(对象的hash码,以及对象将来加锁的锁标记),后 8 字节就是对象的 Class 指针(对象的类型指针,根据该指针可以找到对象的Class类),但目前看不到它Class类的实际地址

5)查看对象 Class 的内存地址

可以通过 Windows -> Console 进入命令行模式,执行

1
mem 0x00000001299b4978 2

mem 有两个参数,参数 1 是对象地址,参数 2 是查看 2 行(即 16 字节)。结果中第二行 0x000000001b7d4028 即为 Class 的内存地址

查看类的vtable

  • 方法1:Alt+R 进入 Inspector 工具,输入刚才的 Class 内存地址,看到如下界面(该类Class对象的所有信息,Class对象在方法区中)

  • 方法2:或者 Tools -> Class Browser 输入 Dog 查找,可以得到相同的结果

无论通过哪种方法,都可以找到 Dog Class 的 vtable 长度为 6,意思就是 Dog 类有 6 个虚方法(多态相关的,final,static 不会列入)

那么这 6 个方法都是谁呢?从 Class 的起始地址开始算,偏移 0x1b8 (这是vtable相对Class对象固定的偏移地址)就是 vtable 的起始地址,进行计算得到:

1
2
3
4
0x000000001b7d4028
1b8 +
---------------------
0x000000001b7d41e0

通过 Windows -> Console 进入命令行模式,执行

1
2
3
4
5
6
7
mem 0x000000001b7d41e0 6
0x000000001b7d41e0: 0x000000001b3d1b10
0x000000001b7d41e8: 0x000000001b3d15e8
0x000000001b7d41f0: 0x000000001b7d35e8
0x000000001b7d41f8: 0x000000001b3d1540
0x000000001b7d4200: 0x000000001b3d1678
0x000000001b7d4208: 0x000000001b7d3fa8

就得到了 6 个虚方法的入口地址.

7)验证方法地址
通过 Tools -> Class Browser 查看每个类的方法定义,比较可知

1
2
3
4
5
6
Dog - public void eat() @0x000000001b7d3fa8
Animal - public java.lang.String toString() @0x000000001b7d35e8;
Object - protected void finalize() @0x000000001b3d1b10;
Object - public boolean equals(java.lang.Object) @0x000000001b3d15e8;
Object - public native int hashCode() @0x000000001b3d1540;
Object - protected native java.lang.Object clone() @0x000000001b3d1678;

对号入座,发现

  • eat() 方法是 Dog 类自己的
  • toString() 方法是继承 Animal 类的
  • finalize() ,equals(),hashCode(),clone() 都是继承 Object 类的

8)小结

vtable
  • 在类加载阶段-链接阶段,生成vtable,确定各个普通成员方法入口地址。
  • vtable中包含和多态相关的方法(被public protected default修饰的方法),不包含被final, static,private修饰的方法。

因为普通成员方法需要在运行时才能确定具体的内容,所以虚拟机需要调用invokevirtual指令。

在执行invokevirtual指令时,经历了以下几个步骤

  • 先通过栈帧中对象的引用找到对象
  • 分析对象头,找到对象实际的在方法区中的Class对象
  • Class结构中有vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
  • 查询vtable找到方法的具体地址
  • 执行方法的字节码

从性能层面上看,调用普通成员方法效率不如调用static,final,private成员方法高,因为他要在运行期间进行动态查找方法入口地址。当然,JVM会对这个查找过程做一定的优化,如果频繁地调用每个方法达到一定的次数,那么JVM会把该方法的字节码地址加载入缓存中,以后从缓存中直接取出方法的字节码地址,不需要再经过这么多步的查找了。另外,如果JVM发现某个类一直以来只调用了一个子类,那么JVM会做一个单态的优化,这也可以加快方法的寻址速度。

异常处理

try-catch
1
2
3
4
5
6
7
8
9
10
public class Demo1 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
}catch (Exception e) {
i = 20;
}
}
}

对应字节码指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
//多出来一个异常表
Exception table:
from to target type
2 5 8 Class java/lang/Exception
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/Exception;
0 13 0 args [Ljava/lang/String;
2 11 1 i I
StackMapTable: ...
MethodParameters: ...
  • 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开(也就是检测2~4行)的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
  • 8行的字节码指令 astore_2 是将异常对象引用存入局部变量表的2号位置(为e)
多个single-catch
1
2
3
4
5
6
7
8
9
10
11
12
public class Demo1 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
}catch (ArithmeticException e) {
i = 20;
}catch (Exception e) {
i = 30;
}
}
}

对应的字节码

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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 26
8: astore_2
9: bipush 30
11: istore_1
12: goto 26
15: astore_2
16: bipush 40
18: istore_1
19: goto 26
22: astore_2
23: bipush 50
25: istore_1
26: return
Exception table:
from to target type
2 5 8 Class java/lang/ArithmeticException
2 5 15 Class java/lang/NullPointerException
2 5 22 Class java/lang/Exception
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/ArithmeticException;
16 3 2 e Ljava/lang/NullPointerException;
23 3 2 e Ljava/lang/Exception;
0 27 0 args [Ljava/lang/String;
2 25 1 i I
StackMapTable: ...
MethodParameters: ...
  • 因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用,这样做可以节省栈帧内存。
multi-catch 的情况

JDK1.7之后,允许同时catch多个异常类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo3_11_3 {    
public static void main(String[] args) {
try {
Method test = Demo3_11_3.class.getMethod("test");
test.invoke(null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
public static void test() {
System.out.println("ok");
}
}
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 static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: ldc #2
2: ldc #3
4: iconst_0
5: anewarray #4
8: invokevirtual #5
11: astore_1
12: aload_1
13: aconst_null
14: iconst_0
15: anewarray #6
18: invokevirtual #7
21: pop
22: goto 30
25: astore_1
26: aload_1
27: invokevirtual #11 // e.printStackTrace:()V
30: return
Exception table:
from to target type
0 22 25 Class java/lang/NoSuchMethodException
0 22 25 Class java/lang/IllegalAccessException
0 22 25 Class java/lang/reflect/InvocationTargetException
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
12 10 1 test Ljava/lang/reflect/Method;
26 4 1 e Ljava/lang/ReflectiveOperationException;
0 31 0 args [Ljava/lang/String;
StackMapTable: ...
MethodParameters: ...

这里异常对象和test对象共用一个局部变量槽位,JVM是非常智能的,他会统计最大需要多少局部变量槽位。

finally

加上finally之后,由于finally块内代码没有被catch,故而JVM字节码会在finally代码块最后补上一个athrow

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo3_11_4 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}

对应字节码

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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1 //locals=4,说明有四个局部变量,但是下面的局部变量表只有3个,说明有一个没有名字的局部变量槽位
0: iconst_0
1: istore_1 // 0->i
2: bipush 10 // try --------------------------------------
4: istore_1 // 10->i |
5: bipush 30 // finally |
7: istore_1 // 30->i |
8: goto 27 // return -----------------------------------
11: astore_2 // catch Exceptin -> e ----------------------
12: bipush 20 // |
14: istore_1 // 20->i |
15: bipush 30 // finally |
17: istore_1 // 30->i |
18: goto 27 // return -----------------------------------
21: astore_3 // catch any -> slot 3 ----------------------
22: bipush 30 // finally |
24: istore_1 // 30->i |
25: aload_3 // <- slot 3 |
26: athrow // throw ------------------------------------
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any // 剩余的异常类型,比如 Error
11 15 21 any // 剩余的异常类型,比如 Error
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
12 3 2 e Ljava/lang/Exception;
0 28 0 args [Ljava/lang/String;
2 26 1 i I
StackMapTable: ...
MethodParameters: ...

可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch剩余的异常类型流程。

注意:虽然从字节码指令看来,每个块中都有finally块,但是finally块中的代码只会被执行一次

finally中的return

下面代码,输出是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo3_12_2 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
try {
return 10;
} finally {
return 20;
}
}
}

对应字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static int test();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> slot 0 (从栈顶移除了)
3: bipush 20 // <- 20 放入栈顶
5: ireturn // 返回栈顶 int(20)
6: astore_1 // catch any -> slot 1
7: bipush 20 // <- 20 放入栈顶
9: ireturn // 返回栈顶 int(20)
Exception table:
from to target type
0 3 6 any
LineNumberTable: ...
StackMapTable: ...
  • 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以finally的为准
  • 至于字节码中第 2 行,似乎没啥用,且留个伏笔,看下个例子
  • 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会吞掉异常catch 匹配到any,在throw之前return了,所以不会throw异常
  • 所以一定不要在finally中进行返回操作,否则会吞掉异常
被吞掉的异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Demo3 {
public static void main(String[] args) {
int i = Demo3.test();
//最终结果为20
System.out.println(i);
}

public static int test() {
int i;
try {
i = 10;
//这里应该会抛出异常
i = i/0;
return i;
} finally {
i = 20;
return i;
}
}
}

会发现打印结果为20,并未抛出异常。

finally不带return

下面代码输出10,因为在执行finally代码块之前,JVM已经把返回值暂存到特定临时变量槽中了,所以后面对i进行赋值,并不会影响返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo4 {
public static void main(String[] args) {
int i = Demo4.test();
System.out.println(i); //10
}

public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}

对应字节码

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 static int test();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10->i
3: iload_0 // <- i(10)
4: istore_1 // 10 -> slot 1,暂存至 slot 1,目的是为了固定返回值
5: bipush 20 // <- 20 放入栈顶
7: istore_0 // 20->i
8: iload_1 // <- slot 1(10) 载入 slot 1 暂存的值
9: ireturn // 返回栈顶的 int(10)
10: astore_2
11: bipush 20
13: istore_0
14: aload_2
15: athrow
Exception table:
from to target type
3 5 10 any
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I
StackMapTable: ...

Synchronized

1
2
3
4
5
6
7
8
public class Demo3_13 {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
}

对应字节码

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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // new Object
3: dup // 复制一份Object实例对象的引用,因为下面一个实例对象的引用要去调用构造方法,另一份要加锁。
4: invokespecial #1 // invokespecial <init>:()V
7: astore_1 // lock引用 -> lock
8: aload_1 // <- lock (synchronized开始)
9: dup // 复制一份Object实例对象的引用,因为下面monitorenter,monitorexit要各消耗一个Object实例对象引用
10: astore_2 // lock引用 -> slot 2
11: monitorenter // monitorenter(lock引用),即加锁
12: getstatic #3 // <- System.out
15: ldc #4 // <- "ok"
17: invokevirtual #5 // invokevirtual println: (Ljava/lang/String;)V
20: aload_2 // <- slot 2(lock引用)
21: monitorexit // monitorexit(lock引用),即正常解锁代码块
22: goto 30
25: astore_3 // any -> slot 3
26: aload_2 // <- slot 2(lock引用)
27: monitorexit // monitorexit(lock引用),即异常解锁代码块
28: aload_3
29: athrow
30: return
Exception table:
from to target type
12 22 25 any // synchronized代码块和正常解锁代码块出现异常,直接跳转到异常解锁代码块。
25 28 25 any // 异常解锁代码块出现异常,直接跳转到异常解锁代码块。
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;
StackMapTable: ...
MethodParameters: ...

为了保证,程序一定能够解锁成功,JVM加上了异常处理机制。

注意:

方法级别的 synchronized 不会在字节码指令中有所体现。

3、编译期处理

所谓的 语法糖 ,其实就是指 java 编译器把 _.java 源码编译为 _.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利。

注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。并不是编译器还会转换出中间的 java 源码,这只是将字节码文件反编译成java文件便于阅读,java编译器转换的结果直接就是 class 字节码。

默认构造函数

1
2
3
public class Candy1 {

}

经过编译期优化后

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

自动拆装箱

基本类型和其包装类型的相互转换过程,称为拆装箱。

在JDK 5以后,它们的转换可以在编译期自动完成,代码片段1:

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

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

1
2
3
4
5
6
7
8
public class Demo2 {
public static void main(String[] args) {
//基本类型赋值给包装类型,称为装箱
Integer x = Integer.valueOf(1);
//包装类型赋值给基本类型,称谓拆箱
int y = x.intValue();
}
}

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

泛型集合取值

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

1
2
3
4
5
6
7
public class Demo3 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10); //这里进行了泛型擦除,实际调用的是 list.add(Objcet o)
Integer x = list.get(0); //这里也进行了泛型擦除,实际调用的是Object x = get(int index)
}
}

对应字节码

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
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: bipush 10
11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
//这里进行了泛型擦除,实际调用的是add(Object o)
14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
19: pop
20: aload_1
21: iconst_0
//这里也进行了泛型擦除,实际调用的是get(int index)
22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
//这里进行了类型转换,将Object转换成了Integer
27: checkcast #7 // class java/lang/Integer
30: astore_2
31: return

LineNumberTable:
line 8: 0
line 9: 8
line 10: 20
line 11: 31
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
8 24 1 list Ljava/util/List;
LocalVariableTypeTable:
Start Length Slot Name Signature
8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;

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

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

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

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

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

擦除的是字节码上的(即方法体内的)泛型信息,可以看到 LocalVariableTypeTable(局部变量类型表) 仍然保留了局部变量的泛型的信息,但是无法通过反射得到这些局部变量的泛型信息。只能通过反射得到方法的参数和返回值的泛型信息。

1
2
public Set<Integer> test(List<String> list, Map<Integer, Object> map) {
}
1
2
3
4
5
6
7
8
9
10
11
12
Method test = Candy3.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes();
for (Type type : types) {
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
System.out.println("原始类型 - " + parameterizedType.getRawType());
Type[] arguments = parameterizedType.getActualTypeArguments();
for (int i = 0; i < arguments.length; i++) {
System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
}
}
}

输出

1
2
3
4
5
原始类型 - interface java.util.List 
泛型参数[0] - class java.lang.String
原始类型 - interface java.util.Map
泛型参数[0] - class java.lang.Integer
泛型参数[1] - class java.lang.Object

可变参数

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

例如:

1
2
3
4
5
6
7
8
9
10
11
public class Demo4 {
public static void foo(String... args) {
//将args赋值给arr,可以看出String...实际就是String[]
String[] arr = args;
System.out.println(arr.length);
}

public static void main(String[] args) {
foo("hello", "world");
}
}

可变参数 String… args 其实是一个 String[] args ,从代码中的赋值语句中就可以看出来。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo4 {
public Demo4 {}


public static void foo(String[] args) {
String[] arr = args;
System.out.println(arr.length);
}

public static void main(String[] args) {
foo(new String[]{"hello", "world"});
}
}

注意,如果调用的是foo(),即未传递参数时,等价代码为foo(new String[]{}),创建了一个空数组,而不是直接传递的null。

foreach

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

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

编译器会帮我们转换为最朴素的for循环遍历数组下标:

1
2
3
4
5
6
7
8
9
10
11
public class Demo5 {
public Demo5 {}

public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5};
for(int i = 0; i < arr.length; ++i) {
int x = arr[i];
System.out.println(x);
}
}
}

如果是集合使用foreach

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

集合要使用foreach,需要该集合类实现了Iterable接口,因为集合的遍历需要用到迭代器Iterator

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo5 {
public Demo5 {}

public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
//获得该集合的迭代器
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()) {
Integer x = iterator.next();
System.out.println(x);
}
}
}

注意

foreach 循环写法,能够配合数组,以及所有实现了 Iterable接口的集合类一起使用,其中 Iterable()方法 用 来获取集合的迭代(Iterator)

switch字符串

从JDK7开始,switch 可以作用于字符串和枚举类,这个功能其实也是语法糖,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo6 {
public static void main(String[] args) {
String str = "hello";
switch (str) {
case "hello" :
System.out.println("h");
break;
case "world" :
System.out.println("w");
break;
default:
break;
}
}
}

注意

switch配合 String 和枚举使用时,变量不能为null,原因分析完语法糖转换后的代码应当自然清楚

在编译器中执行的操作

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
public class Demo6 {
public Demo6() {

}
public static void main(String[] args) {
String str = "hello";
int x = -1;
//通过字符串的hashCode+value来判断是否匹配
switch (str.hashCode()) {
//hello的hashCode
case 99162322 :
//再次比较,因为字符串的hashCode有可能相等
if(str.equals("hello")) {
x = 0;
}
break;
//world的hashCode
case 11331880 :
if(str.equals("world")) {
x = 1;
}
break;
default:
break;
}

//用第二个switch在进行输出判断
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
break;
default:
break;
}
}
}

过程说明:

  • 在编译期间,单个的switch被分为了两个
    • 第一个用来匹配字符串,并给x赋值
      • 字符串的匹配用到了字符串的hashCode,还用到了equals方法
      • 使用hashCode是为了提高比较效率,使用equals是防止有hashCode冲突(如BM和C.)
    • 第二个用来根据x的值来决定输出语句

可以看到,执行了两遍 switch, 第一遍是根据字符串的hashCode和 equals将字符串的转换为相应 byte类 型,第二遍才是利用byte执行进行比较。

为什么第一遍时必须既比较 hashCode, 又利用 equals 比较呢? hashCode是为了提高效率,减少可能的比 较;而 equals 是为了防止 hashCode冲突,例如BMc.这两个字符串的hashCode值都是 2123,如果有如下 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Candy6_2 {
public static void choose(String str) {
switch (str) {
case "BM": {
System.out.println("h");
break;
}
case "C.": {
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
public class Candy6_2 {
public Candy6_2() {
}
public static void choose(String str) {
byte x = -1;
switch(str.hashCode()) {
case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
if (str.equals("C.")) {
x = 1;
} else if (str.equals("BM")) {
x = 0;
}
default:
switch(x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
}

switch枚举

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Demo7 {
public static void main(String[] args) {
SEX sex = SEX.MALE;
switch (sex) {
case MALE:
System.out.println("man");
break;
case FEMALE:
System.out.println("woman");
break;
default:
break;
}
}
}

enum SEX {
MALE, FEMALE;
}

编译器中执行的代码如下

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
public class Demo7 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见),是一个静态内部类,程序员不可见,JVM仅可见
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class $MAP {
//数组大小即为枚举元素个数,里面存放了case用于比较的数字
static int[] map = new int[2];
static {
//ordinal即枚举元素对应所在的位置,MALE为0,FEMALE为1
map[SEX.MALE.ordinal()] = 1;
map[SEX.FEMALE.ordinal()] = 2;
}
}

public static void main(String[] args) {
SEX sex = SEX.MALE;
//将对应位置枚举元素的值赋给x,用于case操作
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("man");
break;
case 2:
System.out.println("woman");
break;
default:
break;
}
}
}

enum SEX {
MALE, FEMALE;
}

枚举类

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

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
20
21
22
23
24
25
public final class Sex extends Enum<Sex> {   
//对应枚举类中的元素
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;

static {
//调用构造函数,传入枚举元素的值及ordinal
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

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

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

} catch( ) {

}

其中资源对象需要实现 AutoCloseable接口,例如 InputstreamOutputstream Connection StatementResultSet 等接口都实现了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:\\1.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 出现异常,作为被压制异常添加
t.addSuppressed(e2);
}
} else {
// 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
is.close();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

JVM会将要抛出的异常的对象的引用存放到一个局部变量里,并将该变量存到方法栈的栈顶等待弹出,此时程序计数器指针指向finally内的代码,遇到下一个要抛出的异常,该异常则顶替先前异常对象引用所在位置,所以程序只会输出finally内的异常。

为什么要设计一个 addsuppressed(Throwab1e e)(添加被压制异常)的方法呢?是为了防止异常信息的丢失(想想 try-with-resources 生成的fianlly 中如果抛出了异常):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test6 {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
int i = 1/0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyResource implements AutoCloseable {
public void close() throws Exception {
throw new Exception("close 异常");
}
}

输出:

1
2
3
4
5
java.lang.ArithmeticException: / by zero
at test.Test6.main(Test6.java:7)
Suppressed: java.lang.Exception: close 异常
at test.MyResource.close(Test6.java:18)
at test.Test6.main(Test6.java:6)

如以上代码所示,两个异常信息都不会丢。

方法重写时的桥接方法

我们都知道,方法重写时对返回值分两种情况:

  • 父子类的返回值完全一致
  • 子类返回值可以是父类返回值的子类(比较绕口,见下面的例子)
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
public java.lang.Integer test.candy.B.m()
public java.lang.Number test.candy.B.m()

匿名内部类

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

转换后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo8 {
public static void main(String[] args) {
//用额外创建的类来创建匿名内部类对象
Runnable runnable = new Demo8$1();
}
}

//创建了一个额外的类,实现了Runnable接口
final class Demo8$1 implements Runnable {
public Demo8$1() {}

@Override
public void run() {
System.out.println("running...");
}
}

如果匿名内部类中引用了局部变量

1
2
3
4
5
6
7
8
9
10
11
public class Demo8 {
public static void main(String[] args) {
int x = 1; //虽然不是final变量,但是代码未对其进行修改,是实际上的final变量
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(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;
Candy11$1(int x) {
this.val$x = x;
}
public void run() {
System.out.println("ok:" + this.val$x);
}
}

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

注意

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

从内部类引用的本地变量必须是最终变量或实际上的最终变量。

4、类加载阶段

关于在什么时候启动类加载阶段的第一个阶段“加载”,《Java虚拟机规范》中没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。

加载

  • 将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
    • _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用。因为C++的instanceKlass不能直接被Java访问,但是.class和instanceKlass互相有各自的地址,这个类镜像起到一个桥梁的作用。
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法
  • 如果这个类还有父类没有加载,先加载父类
  • 加载和链接可能是交替运行

注意

  • instanceKlass 这样的【元数据】是存储在方法区(1.8后的元空间内),但java_mirror是存储在堆中
  • 可以通过前面介绍的 HSDB 工具查看

  • instanceKlass保存在方法区。JDK 8以后,方法区位于元空间中,而元空间又位于本地内存中
  • _java_mirror则是保存在堆内存中,*.class是一个对象,该对象是在加载instanceKlass的同时生成的,放在堆内存中。
  • InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址
  • 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass,从而获取类的各种信息

链接

验证

验证类是否符合 JVM规范,安全性检查

用 UE 等支持二进制的编辑器修改 HelloWorld.class 的魔数,在控制台运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
E:\git\jvm\out\production\jvm>java cn.itcast.jvm.t5.HelloWorld
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 3405691578 in class file cn/itcast/jvm/t5/HelloWorld
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
准备

为 static 变量分配空间,设置默认值

  • static变量在JDK 7以前是存储与instanceKlass末尾。但在JDK 7以后就存储在_java_mirror末尾了
  • static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成。
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
  • 对于String和基本数据类型来说,如果字面量的方式赋值,且static final修饰的话,则显式赋值(直接赋值常量,而非调用方法通常是在链接阶段的准备环节进行。如果是new出来的,则是在初始化阶段()中赋值的情况: 排除上述的在准备环节赋值的。
解析

将常量池中的符号引用解析为直接引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package cn.itcast.jvm.t3.load;
/**
* 解析的含义 */
public class Load2 {
public static void main(String[] args) throws ClassNotFoundException,IOException {
ClassLoader classloader = Load2.class.getClassLoader();
// loadClass 方法不会导致类的解析和初始化,只会导致加载阶段进行。
Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C");
// new C(); 会触发类的加载,链接-解析,初始化。

System.in.read();
}
}
class C {
D d = new D();
}
class D {
}

HSDB的使用

  • 先获得要查看的进程ID
1
jps
  • 打开HSDB
1
java -cp F:\JAVA\JDK8.0\lib\sa-jdi.jar sun.jvm.hotspot.HSDB
  • 运行时可能会报错,是因为缺少一个.dll的文件,我们在JDK的安装目录中找到该文件,复制到缺失的文件下即可

  • 定位需要的进程

解析的含义

将常量池中的符号引用解析为直接引用

  • 未解析时,常量池中的看到的对象仅是符号,未真正的存在于内存中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Demo1 {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ClassLoader loader = Demo1.class.getClassLoader();
//只加载不解析
Class<?> c = loader.loadClass("com.nyima.JVM.day8.C");
//用于阻塞主线程
System.in.read();
}
}

class C {
D d = new D();
}

class D {

}
  • 打开HSDB
    • 可以看到此时只加载了类C

查看类C的常量池,可以看到类D未被解析,只是存在于常量池中的符号

  • 解析以后,会将常量池中的符号引用解析为直接引用
    • 可以看到,此时已加载并解析了类C和类D

初始化

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

发生时机

类的初始化的懒惰的,以下情况会初始化

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

以下情况不会初始化

  • 访问类的 .static final 静态常量(基本类型和字符串)不会触发初始化
  • 类对象.class 不会触发初始化
  • 创建该类对象的数组不会触发初始化
  • 类加载器的.loadClass方法
  • Class.forNamed的参数2为false时

实验如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static 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
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Load3 {
static {
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException {
// 1. 静态常量(基本类型和字符串)不会触发初始化
System.out.println(B.b);
// 2. 类对象.class 不会触发初始化
System.out.println(B.class);
// 3. 创建该类的数组不会触发初始化
System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass("cn.itcast.jvm.t3.B");
// 5. 不会初始化类 B,但会加载 B、A
ClassLoader c2 = Thread.currentThread().getContextClassLoader();
Class.forName("cn.itcast.jvm.t3.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("cn.itcast.jvm.t3.B");
}
}

验证类是否被初始化,可以看改类的静态代码块是否被执行

需要特别注意的是,针对访问类的 static final静态常量(基本类型和字符串)是不是会触发初始化,要分两种情况:static final静态常量只能在定义时或者静态代码块中初始化:

定义时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.jyg.reflection.classload_;

public class ClassLoad02 {
public static void main(String[] args) {

System.out.println(A.num);
}
}

class A {
static {
// num = 2;
System.out.println("static {} runing");
}

public static final int num = 2;
}

输出:

1
2

静态代码块中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.jyg.reflection.classload_;

public class ClassLoad02 {
public static void main(String[] args) {

System.out.println(A.num);
}
}

class A {
static {
num = 2;
System.out.println("static {} running");
}

public static final int num;
}

输出:

1
2
static {} running
2

练习

从字节码分析,使用 a,b,c 这三个常量是否会导致 E 初始化

1
2
3
4
5
6
7
8
9
10
11
12
public class Load4 {
public static void main(String[] args) {
System.out.println(E.a); //不会
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;
}

典型应用 - 完成懒惰初始化单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class Singleton { 
private Singleton() {

}
// 内部类中保存单例
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}

以上的实现特点是:

  • 懒惰实例化
  • 初始化时的线程安全是有保障的(类的加载是线程安全的,有加锁的,你可以看源码)

5、类加载器

以JDK 8为例

名称 加载的类 说明 显示
Bootstrap ClassLoader(启动类加载器) JAVA_HOME/jre/lib 因为它是C++代码,不允许Java直接访问 null
Extension ClassLoader(拓展类加载器) JAVA_HOME/jre/lib/ext 上级为Bootstrap ExtensionClassLoader
Application ClassLoader(应用程序类加载器) classpath 上级为Extension ApplicationClassLoader
自定义类加载器 自定义 上级为Application

每次要加载一个类时,会逐级向上委派,如果上级类加载器已经加载过该类或者上级目录中有该类,上级会加载该类,此时下级不会加载该类,即便下级类加载器路径下有该类。如果上级类加载器已经没有加载过该类并且上级目录中没有该类,上级无法加载该类,此时下级类加载器路径下有该类,下级才会加载该类。

启动类加载器

Bootstrap类加载器一般加载JAVA\_HOME/jre/lib目录下的类,也可以通过JVM虚拟机参数,把自己写的类交由Bootstrap类加载器来加载。

用 Bootstrap 类加载器加载类:

1
2
3
4
5
6
package cn.itcast.jvm.t3.load;
public class F {
static {
System.out.println("bootstrap F init");
}
}

执行

1
2
3
4
5
6
7
package cn.itcast.jvm.t3.load;
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());
}
}

输出

1
2
3
E:\git\jvm\out\production\jvm>java -Xbootclasspath/a:. cn.itcast.jvm.t3.load.Load5_1
bootstrap F init
null
  • -Xbootclasspath 表示设置 bootclasspath

  • 其中 /a:.表示将后面的参数目录追加至 bootclasspath 之后

  • 可以用这个办法替换核心类

    • java -Xbootclasspath:<new bootclasspath>

      java -Xbootclasspath/a:<追加路径>
      java -Xbootclasspath/p:<追加路径>

可通过在控制台输入指令,使得类被启动类加器加载

拓展类加载器

1
2
3
4
5
6
package cn.itcast.jvm.t3.load;
public class G {
static {
System.out.println("classpath G init");
}
}

执行

1
2
3
4
5
6
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());
}
}

输出

1
2
classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2

写一个同名的类

1
2
3
4
5
6
package cn.itcast.jvm.t3.load;
public class G {
static {
System.out.println("ext G init");
}
}

打个 jar 包

1
2
3
E:\git\jvm\out\production\jvm>jar -cvf my.jar cn/itcast/jvm/t3/load/G.class
已添加清单
正在添加: cn/itcast/jvm/t3/load/G.class(输入 = 481) (输出 = 322)(压缩了 33%)

将 jar 包拷贝到 JAVA_HOME/jre/lib/ext

重新执行,输出:

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

如果classpath和JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载

双亲委派模式

双亲委派模式,即调用类加载器ClassLoader 的 loadClass 方法时,查找类的规则

注意

这里的双亲,翻译为上级似乎更为合适,因为它们并没有继承关系

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
29
30
31
32
33
34
35
36
37
38
39
40
41
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// // 1. 检查该类是否已经加载,即查找该类是否已经被该类加载器加载过了
Class<?> c = findLoadedClass(name);
//如果没有被加载过
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 有上级的话,委派上级 loadClass
c = parent.loadClass(name, false);
} else {
// 3. 如果没有上级了(ExtClassLoader),则委派BootstrapClassLoader,其实BootstrapClassLoader就是null
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
//捕获异常,但不做任何处理
}

if (c == null) {
// 4. 如果上级类加载器每一层找不到,调用当前类加载器的 findClass 方法在当前类加载器的指定目录下来加载
// 如果还是没有找到该类,由当前类加载器的 findClass方法抛出ClassNotFoundException异常
long t1 = System.nanoTime();
c = findClass(name);

// 5. 记录耗时
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

例如:

1
2
3
4
5
6
7
public class Load5_3 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Load5_3.class.getClassLoader()
.loadClass("cn.itcast.jvm.t3.load.H");
System.out.println(aClass.getClassLoader());
}
}

执行流程为:

  1. sun.misc.Launcher$AppClassLoader //1 处, 开始查看已加载的类,结果没有
  2. sun.misc.Launcher$AppClassLoader // 2 处,委派上级sun.misc.Launcher$ExtClassLoader.loadClass()
  3. sun.misc.Launcher$ExtClassLoader // 1 处,查看已加载的类,结果没有
  4. sun.misc.Launcher$ExtClassLoader // 3 处,没有上级了,则委派 BootstrapClassLoader查找
  5. BootstrapClassLoader 是在 JAVA_HOME/jre/lib 下找 H 这个类,显然没有
  6. sun.misc.Launcher$ExtClassLoader // 4 处,调用自己的findClass方法,是在JAVA_HOME/jre/lib/ext 下找H这个类,显然没有,回到 sun.misc.Launcher$AppClassLoader的 // 2 处
  7. 继续执行到 sun.misc.Launcher$AppClassLoader // 4 处,调用它自己的 findClass 方法,在classpath 下查找,找到了

线程上下文类加载器

我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写

1
Class.forName("com.mysql.jdbc.Driver")

也是可以让 com.mysql.jdbc.Driver 正确加载的,你知道是怎么做的吗?

让我们追踪一下源码:

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

打印 null,表示它的类加载器是 Bootstrap ClassLoader,会到 JAVA_HOME/jre/lib 下搜索类,但 JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包,这样问题来了,在 DriverManager 的静态代码块中,怎么能正确加载 com.mysql.jdbc.Driver 呢?

继续看 loadInitialDrivers() 方法:

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
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// 1)使用 ServiceLoader 机制加载驱动,即 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;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
// 2)使用 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);
// 这里的 ClassLoader.getSystemClassLoader() 就是应用程序类加载器
// DriverManager的类加载器是BootstrapClassLoader,这里却使用的是应用程序类加载器,打破了JVM的双亲委派机制
Class.forName(aDriver, true, ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}

先看 2)发现它最后是使用 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);
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service, "Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service, "Provider " + cn + " not a subtype");
} try {
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service, "Provider " + cn + " could not be instantiated", x);
}
throw new Error(); // This cannot happen
}

小结:

  • 1、在jre/lib包下有一个DriverManager,是BootstrapClassLoader加载的,但是jdbc的驱动是各个厂商来实现的不在启动类加载路径下,启动类无法加载,而驱动管理需要用到这些驱动。
  • 2、只能打破双亲委派,启动类直接请求系统类加载器去classpath下加载驱动(正常是向上委托,这个反过来了),而打破双亲委派的就是这个线程上下文类加载器
  • 3、过程就是:启动类加载器加载DriverManager,DriverManager代码里调用了线程上下文类加载器,这个加载器默认就是使用应用程序类加载器加载类,通过应用程序类加载器加载jdbc驱动

自定义类加载器

使用场景
  • 想加载非 classpath 随意路径中的类文件
  • 通过接口来使用实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤
  • 继承ClassLoader父类
  • 要遵从双亲委派机制,重写 findClass 方法
    • 不是重写loadClass方法,否则不会走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法来加载类
  • 使用者调用该类加载器的 loadClass 方法

示例:
准备好一个字节码(MapImpl1.class)文件放入 E:\myclasspath,它实现了 java.util.Map 接口:

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
package cn.itcast.jvm.t3.load;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Load7 {
public static void main(String[] args) throws Exception {
MyClassLoader classLoader = new MyClassLoader();
Class<?> c1 = classLoader.loadClass("MapImpl1");
Class<?> c2 = classLoader.loadClass("MapImpl1");
System.out.println(c1 == c2); //true

MyClassLoader classLoader2 = new MyClassLoader();
Class<?> c3 = classLoader2.loadClass("MapImpl1");
System.out.println(c1 == c3); //false

c1.newInstance();
}
}

class MyClassLoader extends ClassLoader {

@Override // name 就是类名称
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);
}
}
}

两个Class对象是否完全相同,要比较包名+类名+类加载器对象。三者完全相同,两个Class对象才相同。

自定义的类加载器,在调用构造方法时,会调用继承的ClassLoader的有参构造器,会将线程上下文类加载器(ApplicationClassLoader)放入当前类加载器对象的上级字段中,所以自定义类加载器的上级是ApplicationClassLoader

6、运行期优化

分层编译

分层编译

(TieredCompilation) 先来个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package cn.itcast.jvm.t3.jit;

public class JIT1 {

// -XX:+PrintCompilation -XX:-DoEscapeAnalysis
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));
}
}
}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
0   96426
1 52907
2 44800
3 119040
4 65280
5 47360
6 45226
7 47786
8 48640
9 60586
10 42667
11 48640
12 70400
13 49920
14 49493
15 45227
16 45653
17 60160
18 58880
19 46080
20 47787
21 49920
22 54187
23 57173
24 50346
25 52906
26 50346
27 47786
28 49920
29 64000
30 49067
31 63574
32 63147
33 56746
34 49494
35 64853
36 107520
37 46933
38 51627
39 45653
40 103680
41 51626
42 60160
43 49067
44 45653
45 49493
46 51626
47 49066
48 47360
49 50774
50 70827
51 64000
52 72107
53 49066
54 46080
55 44800
56 46507
57 73813
58 61013
59 57600
60 83200
61 7024204
62 49493
63 20907
64 20907
65 20053
66 20906
67 20907
68 21333
69 22187
70 20480
71 21760
72 19200
73 15360
74 18347
75 19627
76 17067
77 34134
78 19200
79 18347
80 17493
81 15360
82 18774
83 17067
84 21760
85 23467
86 17920
87 17920
88 18774
89 18773
90 19200
91 20053
92 18347
93 22187
94 17920
95 18774
96 19626
97 33280
98 20480
99 20480
100 18773
101 47786
102 17493
103 22614
104 64427
105 18347
106 19200
107 26027
108 21333
109 20480
110 24747
111 32426
112 21333
113 17920
114 17920
115 19200
116 18346
117 15360
118 24320
119 19200
120 20053
121 17920
122 18773
123 20053
124 18347
125 18347
126 22613
127 18773
128 19627
129 20053
130 20480
131 19627
132 20053
133 15360
134 136533
135 43093
136 853
137 853
138 853
139 853
140 854
141 853
142 853
143 853
144 853
145 853
146 853
147 854
148 853
149 853
150 854
151 853
152 853
153 853
154 1280
155 853
156 853
157 854
158 853
159 853
160 854
161 854
162 853
163 854
164 854
165 854
166 854
167 853
168 853
169 854
170 853
171 853
172 853
173 1280
174 853
175 1280
176 853
177 854
178 854
179 427
180 853
181 854
182 854
183 854
184 853
185 853
186 854
187 853
188 853
189 854
190 1280
191 853
192 853
193 853
194 853
195 854
196 853
197 853
198 853
199 854

原因是什么呢?

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

  • 0层:解释执行,用解释器将字节码翻译为机器码
  • 1层:使用 C1 即时编译器编译执行(不带 profiling)
  • 2层:使用 C1 即时编译器编译执行(带基本的profiling)
  • 3层:使用 C1 即时编译器编译执行(带完全的profiling)
  • 4层:使用 C2 即时编译器编译执行

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

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

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

逃逸分析

C2即时编译器中有一种优化手段称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用 -XX:- DoEscapeAnalysis 关闭逃逸分析,再运行刚才的示例观察结果

参考资料:Oracle官方文档

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。

逃逸分析的 JVM 参数如下:

  • 开启逃逸分析:-XX:+DoEscapeAnalysis
  • 关闭逃逸分析:-XX:-DoEscapeAnalysis
  • 显示分析结果:-XX:+PrintEscapeAnalysis

逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数

对象逃逸状态

全局逃逸(GlobalEscape)

  • 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
    • 对象是一个静态变量
    • 对象是一个已经发生逃逸的对象
    • 对象作为当前方法的返回值

参数逃逸(ArgEscape)

  • 即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的

没有逃逸

  • 即方法中的对象没有发生逃逸

逃逸分析优化

针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化

锁消除

我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁

例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作

锁消除的 JVM 参数如下:

  • 开启锁消除:-XX:+EliminateLocks
  • 关闭锁消除:-XX:-EliminateLocks

锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上

标量替换

首先要明白标量和聚合量,基础类型对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能

标量替换的 JVM 参数如下:

  • 开启标量替换:-XX:+EliminateAllocations
  • 关闭标量替换:-XX:-EliminateAllocations
  • 显示标量替换详情:-XX:+PrintEliminateAllocations

标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上

栈上分配

当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能。

方法内联

(Inlining)

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

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

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

还能够进行常量折叠(constant folding)的优化

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
21
22
23
24
25
26
27
package cn.itcast.jvm.t3.jit;

import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

public class JIT2 {
// -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:CompileCommand=dontinline,*JIT2.square
// -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 基准测试请参考:http://openjdk.java.net/projects/code-tools/jmh/

创建 maven 工程,添加依赖如下

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package test;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;

import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

@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 方法没有内联,则不会进行上面的优化

练习:在内联情况下将 elements 添加 volatile 修饰符,观察测试结果

反射优化

1
2
3
4
5
6
7
8
9
10
11
12
public class Reflect1 {
public static void foo() {
System.out.println("foo...");
}

public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Method foo = Demo3.class.getMethod("foo");
for(int i = 0; i<=16; i++) {
foo.invoke(null);
}
}
}

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

Method类的invoke方法源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
//MethodAccessor是一个接口,有3个实现类,其中有一个是抽象类
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

会由DelegatingMehodAccessorImpl去调用NativeMethodAccessorImplinvoke方法

NativeMethodAccessorImpl源码

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
class NativeMethodAccessorImpl extends MethodAccessorImpl {
private final Method method;
private DelegatingMethodAccessorImpl parent;
private int numInvocations;

NativeMethodAccessorImpl(Method var1) {
this.method = var1;
}

//每次进行反射调用,会让numInvocation与ReflectionFactory.inflationThreshold的值(15)进行比较,并使使得numInvocation的值加一
//如果numInvocation>ReflectionFactory.inflationThreshold,则会调用本地方法invoke0方法
public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
// inflationThreshold 膨胀阈值,默认 15
if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
MethodAccessorImpl var3 =
(MethodAccessorImpl)
(new MethodAccessorGenerator())
.generateMethod(
this.method.getDeclaringClass(),
this.method.getName(),
this.method.getParameterTypes(),
this.method.getReturnType(),
this.method.getExceptionTypes(),
this.method.getModifiers()
);
this.parent.setDelegate(var3);
}

return invoke0(this.method, var1, var2);
}

void setParent(DelegatingMethodAccessorImpl var1) {
this.parent = var1;
}

private static native Object invoke0(Method var0, Object var1, Object[] var2);
}
1
2
//ReflectionFactory.inflationThreshold()方法的返回值
private static int inflationThreshold = 15;
  • 一开始if条件不满足,就会调用本地方法invoke0,本地方法的调用比较费时。
  • 随着numInvocation的增大,当它大于ReflectionFactory.inflationThreshold的值16时,就由本地方法访问器替换为一个运行时动态生成的访问器(构造一个对象,来直接调用方法),来提高效率;当调用到第 16 次(从0开始算)时,会采用运行时生成的类代替掉最初的实现,可以通过 debug 得到 类名为 sun.reflect.GeneratedMethodAccessor1。这时会从反射调用变为正常调用,即直接调用 Reflect1.foo()

可以使用阿里的 arthas 工具:

1
2
3
4
java -jar arthas-boot.jar
[INFO] arthas-boot version: 3.1.1
[INFO] Found existing java process, please choose one and hit RETURN.
* [1]: 13065 cn.itcast.jvm.t3.reflect.Reflect1

选择 1 回车表示分析该进程

1
2
3
4
5
6
7
8
9
10
11
12
1
[INFO] arthas home: /root/.arthas/lib/3.1.1/arthas
[INFO] Try to attach process 13065
[INFO] Attach process 13065 success.
[INFO] arthas-client connect 127.0.0.1 3658
,---. ,------. ,--------.,--. ,--. ,---. ,---. / O \| .--.''--. .--'| '--' |/ O \' .-' |.-.||'--'.' || |.--.||.-.|`.`-.
||||||\\ || ||||||||.-' | `--' `--'`--' '--' `--' `--' `--'`--' `--'`-----'
wiki https://alibaba.github.io/arthas
tutorials https://alibaba.github.io/arthas/arthas-tutorials
version 3.1.1
pid 13065
time 2019-06-10 12:23:54

再输入【jad + 类名】来进行反编译

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
$ jad sun.reflect.GeneratedMethodAccessor1
ClassLoader:
+-sun.reflect.DelegatingClassLoader@15db9742
+-sun.misc.Launcher$AppClassLoader@4e0e2f2a
+-sun.misc.Launcher$ExtClassLoader@2fdb006e
Location:
/*
* Decompiled with CFR 0_132.
*
* Could not load the following classes:
* cn.itcast.jvm.t3.reflect.Reflect1
*/
package sun.reflect;
import cn.itcast.jvm.t3.reflect.Reflect1;
import java.lang.reflect.InvocationTargetException;
import sun.reflect.MethodAccessorImpl;
public class GeneratedMethodAccessor1
extends MethodAccessorImpl {
/*
* Loose catch block
* Enabled aggressive block sorting
* Enabled unnecessary exception pruning
* Enabled aggressive exception aggregation
* Lifted jumps to return sites
*/
public Object invoke(Object object, Object[] arrobject) throws InvocationTargetException {
// 比较奇葩的做法,如果有参数,那么抛非法参数异常 block4 : {
if (arrobject == null || arrobject.length == 0) break block4;
throw new IllegalArgumentException();
}
try {
// 可以看到,已经是直接调用了😱😱😱 Reflect1.foo();
// 因为没有返回值
return null;
}
catch (Throwable throwable) {
throw new InvocationTargetException(throwable);
}
catch (ClassCastException | NullPointerException runtimeException) {
throw new IllegalArgumentException(Object.super.toString());
}
}
}
Affect(row-cnt:1) cost in 1540 ms.

注意

通过查看 ReflectionFactory 源码可知

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

五、内存模型

很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java Memory Model(JMM)的意思。

关于它的权威解释,请参考 https://download.oracle.com/otn-pub/jcp/memory_model-1.0-pfdspec-oth-JSpec/memory_model-1_0-pfd-spec.pdf?AuthParam=1562811549_4d4994cbd5b59d964cd2907ea22ca08b

简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障

  • 很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java Memory Model(JMM)的意思。
  • 简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性有序性、和原子性的规则和保障

1. 原子性

原子性在学习线程时讲过,下面来个例子简单回顾一下:

提出问题:两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

1-1 问题解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Demo1 {
static int i = 0;

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
i--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

1
2
3
4
getstatic	i 	// 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i

而对应 i– 也是类似:

1
2
3
4
getstatic	i 	// 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换:

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

1
2
3
4
5
6
7
8
9
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
getstatic i // 线程1-获取静态变量i的值 线程内i=1
iconst_1 // 线程1-准备常量1
isub // 线程1-自减 线程内i=0
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行(为什么会交错?思考一下): 出现负数的情况:

1
2
3
4
5
6
7
8
9
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

出现正数的情况:

1
2
3
4
5
6
7
8
9
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1

1-2 解决方法

(1)synchronized(同步关键字)

语法:

1
2
3
synchronized( 对象 ) {
要作为原子操作代码
}

synchronized 解决并发问题:

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
public class Demo1 {
static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
synchronized (obj) {
i++;
}

}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);//输出为0
}
}

为什么需要这里的 obj 对象呢?

我们可以这样理解:可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。

当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++ 代码。这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。

当 t1 执行完 synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count– 代码。

注意:上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1 对 象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

怎么从JVM角度理解呢?(这里引用《Java并发编程的艺术》里的一段话)

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorentermonitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是,方法同步同样可以使用这两个指令来实现。 monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

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
public class Demo1 {
static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {
synchronized (obj) {
for (int j = 0; j < 50000; j++) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (obj) {
for (int j = 0; j < 50000; j++) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);//输出为0
}
}

这里做了一个优化,将synchronized放在for循环之外,每个线程的加锁解锁次数从5000次变为了1次(synchronized是可重入的的),提高了效率。

2.可见性

2-1 退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

1
2
3
4
5
6
7
8
9
10
11
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}

为什么会这样?

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

  1. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

2-2 解决办法

(1)volatile(易变关键字)

它可以用来修饰成员变量静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存,保证了共享变量的可见性,但不能保证原子性,仅适用在一个写线程,多个读线程的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo1 {
volatile static boolean run = true;

public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}

}

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况:

上例从字节码理解是这样的:

1
2
3
4
5
6
getstatic	run //线程t获取runtrue
getstatic run //线程t获取runtrue
getstatic run //线程t获取runtrue
getstatic run //线程t获取runtrue
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false

比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i– ,只能保证看到最新值,不能解决指令交错

1
2
3
4
5
6
7
8
9
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

注意

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也 能正确看到对 run 变量的修改了,想一想为什么?

进入println源码:

1
2
3
4
5
6
public void println(int x) {
synchronized (this) {
print(x);
newLine();
}
}

可以看出加了synchronized,保证了每次run变量都会从主存中获取。

3.有序性

3-1 诡异的结果

看下面一个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int num = 0;
boolean ready = false;

// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}

// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

看到这里可能聪明的小伙伴会想到有下面三种情况:

情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1

情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

但其实还有可能为0哦!线程 2 执行 ready=true ,切换到线程1 ,进入if分支,相加为0,在切回线程 2 执行 num=2

指令重排

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:

借助 java 并发压测工具 jcstress https://wiki.openjdk.java.net/display/CodeTools/jcstress

1
mvn archetype:generate  -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DgroupId=org.sample -DartifactId=test -Dversion=1.0

创建 maven 项目,提供如下测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}

执行

1
2
mvn clean install 
java -jar target/jcstress.jar

会输出我们感兴趣的结果,摘录其中一次结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
2 matching test results.
[OK] test.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 1,729 ACCEPTABLE_INTERESTING !!!!
1 42,617,915 ACCEPTABLE ok
4 5,146,627 ACCEPTABLE ok
[OK] test.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 1,652 ACCEPTABLE_INTERESTING !!!!
1 46,460,657 ACCEPTABLE ok
4 4,571,072 ACCEPTABLE ok

可以看到,出现结果为 0 的情况有 638 次,虽然次数相对很少,但毕竟是出现了。

3-2 解决方法

volatile 修饰的变量,可以禁用指令重排

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;//可以禁用指令重排
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}

重新测试,发现未发生指令重排现象。

3-3 有序性理解

同一线程内,JVM会在不影响正确性的前提下,可以调整语句的执行顺序,看看下面的代码:

1
2
3
4
5
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时, 既可以是

1
2
i = ...; // 较为耗时的操作
j = ...;

也可以是

1
2
j = ...;
i = ...; // 较为耗时的操作

这种特性称之为指令重排多线程下指令重排会影响正确性,例如著名的 double-checked locking 模式实现单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {
private Singleton() {
}

private static Singleton INSTANCE = null;

public static Singleton getInstance() {
//实例没创建,才会进入内部的 synchronized 代码块
if (INSTANCE == null) {
//可能第一个线程在synchronized 代码块还没创建完对象时,第二个线程已经到了这一步,所以里面还需要加上判断
synchronized (Singleton.class) {
//也许有其他线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用synchronized加锁,后续使用时无需加锁

上面的代码看似已经很完美了,但是在多线程环境下还是会有指令重排问题!

INSTANCE = new Singleton() 对应的字节码为:

1
2
3
4
0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field INSTANCE:Lcn/itcast/jvm/t4/Singleton;

其中47两步顺序不是固定的,也许 jvm 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间顺序执行:

1
2
3
4
5
时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != nullsynchronized块外),直接返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {
private Singleton() {
}

private static volatile Singleton INSTANCE = null;

public static Singleton getInstance() {
//实例没创建,才会进入内部的 synchronized 代码块
if (INSTANCE == null) {
//可能第一个线程在synchronized 代码块还没创建完对象时,第二个线程已经到了这一步,所以里面还需要加上判断
synchronized (Singleton.class) {
//也许有其他线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

3-4 happens-before

happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static int x;
    static Object m = new Object();
    new Thread(()->{
    synchronized(m) {
    x = 10;
    }
    },"t1").start();
    new Thread(()->{
    synchronized(m) {
    System.out.println(x);
    }
    },"t2").start()
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

    1
    2
    3
    4
    5
    6
    7
    8
    volatile static int x;
    new Thread(()->{
    x = 10;
    },"t1").start();
    new Thread(()->{
    System.out.println(x);
    },"t2").start();

  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    1
    2
    3
    4
    5
    6
    static int x;
    x = 10;
    new Thread(()->{
    System.out.println(x);
    },"t2").start();

  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()t1.join()等待它结束)

    1
    2
    3
    4
    5
    6
    7
    8
    static int x;
    Thread t1 = new Thread(()->{
    x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);

  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程(包括被打断线程)得知 t2 被打断后对变量的读可见(通过t2.interruptedt2.isInterrupted()

    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 int x;
    public static void main(String[] args) {
    Thread t2 = new Thread(()->{
    while(true) {
    if(Thread.currentThread().isInterrupted()) {
    System.out.println(x);//0
    break;
    }
    }
    },"t2");
    t2.start();
    new Thread(()->{
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    x = 10;
    t2.interrupt();
    },"t1").start();
    while(!t2.isInterrupted()) {
    Thread.yield();
    }
    System.out.println(x);//0
    }

  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

以上变量都是指共享变量即成员变量或静态资源变量

4.CAS与原子类

4-1 CAS

CASCompare and Swap ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 需要不断尝试
while(true) {
int 旧值 = 共享变量 ; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if( compareAndSwap ( 旧值, 结果 )) {
// 成功,退出循环
}
//不一样,继续循环尝试
}

CompareAndSwap,是根据地址再取一次旧值(新旧值),然后与旧值(旧旧值)比较,如果一样就set(内部保证原子性)结果,不一样就再试。

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈(不要频繁重试)、多核 CPU(重试需要消耗CPU时间,所以多核CPU提供更多CPU时间片) 的场景下。

  • 因为没有使用 synchronized,所以线程不会陷入阻塞(涉及到线程的上下文切换),这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子:

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
57
58
59
60
61
62
63
64
65
66
67
public class TestCAS {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t.start();
t.join();
System.out.println(dc.getData());
}
}

class DataContainer {
private volatile int data;
static final Unsafe unsafe;
static final long DATA_OFFSET;

static {
try {
// Unsafe 对象不能直接调用,只能通过反射获得
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
}
try {
// data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
DATA_OFFSET =
unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new Error(e);
}
}

public void increase() {
int oldValue;
while (true) {
// 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
oldValue = data;
// 旧值与当前共享变量一致,才做修改。否则,不修改,重新尝试。
// cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue +
1)) {
return;
}
}
}

public void decrease() {
int oldValue;
while (true) {
oldValue = data;
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - 1)) {
return;
}
}
}

public int getData() {
return data;
}
}

4-2 乐观锁与悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会

4-3 原子操作类

**JUC(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicIntegerAtomicBoolean等,它们底层就是采用 **CAS 技术 + volatile 来实现的。 可以使用 AtomicInteger 改写之前的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TestCAS {
//创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndIncrement(); //获取并且自增 i++
// i.incrementAndGet(); //自增并且获取 ++i
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); //获取并且自减 i--
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);//0
}
}

5.synchronized 优化

从JDK1.6开始,对synchronized做了大量优化,在某些场景下甚至比CAS性能要更好。

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针Mark Word)。Mark Word 平时存储这个对象的哈希码 、 分代年龄 ;当加锁时,这些信息就根据情况被替换为标记位 、 线程锁记录指针 、 重量级锁指针 、 线程ID 等内容

5-1 轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。这就好比:

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。 如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程A 随即升级为重量级锁,进入重量级锁的流程。

而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来,假设有两个方法同步块,利用同一个对象加锁

1
2
3
4
5
6
7
8
9
10
11
12
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}

每个线程的栈帧中都会包含一个锁记录(栈结构,每次加锁push对象的Mark Word,解锁pop对象的Mark Word)的结构,内部可以存储锁定对象的 Mark Word,即对象Mark Word做修改之前,先把当前Mark Word存放在当前线程栈帧的锁记录的结构中,以便之后恢复。即每次加锁的过程就是,对象的Mark Word与当前线程的锁记录结构中的锁记录地址进行交换。

线程1 对象Mark Word 线程2
访问同步块 A,把 Mark 复制到 线程 1 的锁记录(线程1锁记录push对象的Mark Word) 01(无锁) -
CAS 修改 Mark 为线程 1 锁记录 地址 01(无锁) -
成功(加锁) 00(轻量锁)线程 1 - 锁记录地址 -
执行同步块 A 00(轻量锁)线程 1 - 锁记录地址 -
访问同步块 B,把 Mark 复制到 线程 1 的锁记录(线程1锁记录push对象的Mark Word) 00(轻量锁)线程 1 - 锁记录地址 -
CAS 修改 Mark 为线程 1 锁记录地址 00(轻量锁)线程 1 - 锁记录地址 -
失败(发现是自己的锁) 00(轻量锁)线程 1 - 锁记录地址 -
锁重入 00(轻量锁)线程 1 - 锁记录地址 -
执行同步块 B 00(轻量锁)线程 1 - 锁记录地址 -
同步块 B 执行完毕(线程1锁记录pop出一个Mark Word) 00(轻量锁)线程 1 - 锁记录地址 -
同步块 A 执行完毕(线程1锁记录pop出一个Mark Word,并且交换和对象的Mark Word) 00(轻量锁)线程 1 - 锁记录地址 -
成功(解锁) 01(无锁) -
- 01(无锁) 访问同步块 A,把 Mark 复制到 线程 2 的锁记录
- 01(无锁) CAS 修改 Mark 为线程 2 锁记录 地址
- 00(轻量锁)线程 2 锁记录地址 成功(加锁)
-

5-2 锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

1
2
3
4
5
6
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
线程1 对象Mark Word 线程2
访问同步块 A,把 Mark 复制到 线程 1 的锁记录 01(无锁) -
CAS 修改 Mark 为线程 1 锁记录 地址 01(无锁) -
成功(加锁) 00(轻量锁)线程 1 - 锁记录地址 -
执行同步块 00(轻量锁)线程 1 - 锁记录地址 -
执行同步块 00(轻量锁)线程 1 - 锁记录地址 访问同步块,把 Mark 复制 到线程 2 锁记录
执行同步块 00(轻量锁)线程 1 - 锁记录地址 CAS 修改 Mark 为线程 2 锁记录地址
执行同步块 00(轻量锁)线程 1 - 锁记录地址 失败(发现别人已经占了锁)
执行同步块 00(轻量锁)线程 1 - 锁记录地址 CAS 修改 Mark 为重量锁
执行同步块 10(重量锁)重量锁指针 阻塞中
执行完毕 10(重量锁)重量锁指针 阻塞中
失败(解锁) 10(重量锁)重量锁指针 阻塞中
释放重量锁,唤起阻塞线程竞争 01(无锁) 阻塞中
- 10(重量锁) 竞争重量锁
- 10(重量锁) 成功(加锁)
-

对象Mark Word设置重量锁指针是为了,重量锁被释放后,根据重量锁指针来唤醒被阻塞的线程们来竞争重量锁。

5-3 重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于线程阻塞(等待时间长了划算)
  • Java 7 之后不能控制是否开启自旋功能

自旋重试成功的情况

线程1(cpu1上) 对象Mark 线程2(cpu2上)
- 10(重量锁) -
访问同步块,获取monitor 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行完毕 10(重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10(重量锁)重量锁指针 成功(加锁)
- 10(重量锁)重量锁指针 执行同步块
-

自旋重试失败的情况

线程1(cpu1上) 对象Mark 线程2(cpu2上)
- 10(重量锁) -
访问同步块,获取monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 阻塞
-

5-4 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
  • 访问对象的 hashCode 也会撤销偏向锁,因为此时Mark Word(含hashCode)被换成了线程ID
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
  • 撤销偏向和重偏向都是批量进行的,以类为单位
  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
  • 由于偏向锁的种种弊端,可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

可以参考这篇论文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

假设有两个方法同步块,利用同一个对象加锁

1
2
3
4
5
6
7
8
9
10
11
static Object obj = new Object();
public static void method1() {
synchronized( obj ) { // 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
线程1 对象Mark
访问同步块 A,检查 Mark 中是否有线程 ID 101(无锁可偏向)
尝试加偏向锁 101(无锁可偏向)对象 hashCode
成功 101(无锁可偏向)线程ID
执行同步块 A 101(无锁可偏向)线程ID
访问同步块 B,检查 Mark 中是否有线程 ID 101(无锁可偏向)线程ID
是自己的线程 ID,锁是自己的,无需做更多操作 101(无锁可偏向)线程ID
执行同步块 B 101(无锁可偏向)线程ID
执行完毕 101(无锁可偏向)对象hashCode

5-5 其他优化

(1)减少上锁时间

同步代码块中尽量短,同步代码块运行时间短,竞争的机会就少了。

(2)减少锁的粒度

将一个锁拆分为多个锁提高并发度,例如:

  • ConcurrentHashMap,将数组中的各个链表头进行加锁,而不是像HashTable一样对整个数组进行加锁。
  • LongAdder 分为 basecells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加到base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
  • LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
(3)锁粗化

多次循环进入同步块不如同步块内多次循环,另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

1
new StringBuffer().append("a").append("b").append("c");
(4)锁消除

JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。

(5)读写分离

CopyOnWriteArrayListConyOnWriteSet读操作直接读原始数组,而写操作在副本数组上写,故而不需要对读操作进行同步,只需要对写操作进行同步。

参考:

https://wiki.openjdk.java.net/display/HotSpot/Synchronization

http://luojinping.com/2015/07/09/java锁优化/

https://www.infoq.cn/article/java-se-16-synchronized

https://www.jianshu.com/p/9932047a89be

https://www.cnblogs.com/sheeva/p/6366782.html

https://stackoverflow.com/questions/46312817/does-java-ever-rebias-an-individual-lock


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!