基础篇
一、什么是JVM
1)定义
Java Virtual Machine ,Java 程序的运行环境(Java 二进制字节码的运行环境)。
2)好处
- 一次编译,处处执行(跨平台)
- 自动的内存管理,垃圾回收机制
- 数组下标越界检查
3)功能
- 对字节码文件中的指令,实时的解释成机器码,让计算机执行
- 自动为对象、方法等分配内存,自动的垃圾回收机制,回收不再使用的对象
- 对热点代码进行优化,提升执行效率
4)比较
JVM、JRE、JDK 的关系如下图所示

二、学习 JVM 有什么用?
- 面试必备
- 中高级程序员必备
- 想走的长远,就需要懂原理,比如:自动装箱、自动拆箱是怎么实现的,反射是怎么实现的,垃圾回收机制是怎么回事等待,JVM 是必须掌握的。
三、JVM的组成

ClassLoader:Java 代码编译成二进制后,会经过类加载器,这样才能加载到 JVM 中运行。
Method Area:类是放在方法区中。
Heap:类的实例对象。静态变量存储在堆中
当类调用方法时,会用到 JVM Stack、PC Register、本地方法栈。
方法执行时的每行代码是有执行引擎中的解释器逐行执行,方法中的热点代码频繁调用的方法,由 JIT 编译器优化后执行,GC 会对堆中不用的对象进行回收。需要和操作系统打交道就需要使用到本地方法接口。
四、字节码文件组成

1)魔数
magic
对应字节码文件的 0~3 个字节,即前4个字节
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
ca fe ba be :意思是 .class 文件,不同的文件类型有不同的魔数,比如 jpg、png 图片等!
JVM在加载字节码文件时,会对前4个字节进行校验,如果不是ca fe ba be,就会提示报错
2)版本
minor_version:副版本号,主要用在主版本号相同时进行区分不同的版本
major_version:主版本号,用来表示大版本号
版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。比如说,使用JDK7环境运行JDK17的字节码文件,就会出现不兼容的情况
例如如下报错信息:从报错信息得知,类文件版本是52,即JDK8,而运行环境的JDK版本是JDK6。解决方法是,降低依赖的版本号,不建议升级JDK版本,因为这样会引发其他兼容性问题,需要大量的验证测试

3)常量池
作用:避免相同的内容重复定义,浪费空间
- 常量池中的数据都有一个编号,编号从1开始。在字段或者字节码指令中通过编号可以快速的找到对应的数据
- 字节码指令中通过编号引用到常量池的过程称之为符号引用
4)方法
存储当前类或接口声明的方法信息。
我们有这样一段java代码:
java
public static void main(String[] args){
int i = 0;
int j = i + 1;
}
对它进行编译后,使用jclasslib工具打开该字节码文件
我们找到【方法】、main,点击code即可查看对应的字节码指令。
鼠标左键点击指令,点击【显示JVM规范】,即可跳转到浏览器Oracle官方文档
接下来看下字节码指令分析
左边是我们的java源代码,中间是对应的字节码指令,右边是两块内存区域:操作数栈、局部变量表数组。
操作数栈:临时存放一些数据,比如要将1和2进行相加,就需要将1和2放入操作数栈,将加后的结果放到操作数栈。
局部变量表数组:存放方法的局部变量,如图,变量i是放在了数组下标1,变量j是放在了数组下标2。那数组下标0存的是什么?
我们回到jclasslib,点击上图可以看到,数组下标0的位置存的是main方法的形参args
分析流程

1、首先通过iconst_<i>
指令加载数据到操作数栈,其中<i>
表示要加载的数据。这里是iconst_0
就是将0加载到操作数栈
2、通过istore_<i>
指令从操作数栈取出数据放入局部变量表,这里的<i>
表示局部变量表的下标。所以,istore_1
就是从操作数栈中取出数据0放入到局部变量表的下标1位置
3、通过iload_<i>
指令从局部变量表中复制一份数据到操作数栈,这里的<i>
表示局部变量表的下标。所以,iload_1
就是从局部变量表的下标1位置中取出数据0放入到操作数栈中
4、通过iconst_1
指令将常量1放入操作数栈
5、通过iadd
指令将操作数栈中的两个数进行相加,结果放入操作数栈
6、通过istore_2
指令将操作数栈中的1放入局部变量表的下标2位置
7、执行return返回
看下另外一个java程序
java
public static void main(String[] args){
int i = 0;
i = i++;
System.out.println(i);
}
它的输出结果是0
对应字节码指令如下:
java
0 iconst_0
1 istore_1
2 iload_1
3 iinc 1 by 1
6 istore_1
7 return

1、执行iconst_0
指令将常量0放入操作数栈
2、执行istore_1
指令将操作数栈中的0放入局部变量表的1号位置
3、执行iload_1
指令,从局部变量表1中复制数据0到操作数栈
4、执行iinc 1 by 1
,第1个1表是局部变量表的1号位置,第二个1表示要加的数。完整指令的含义是,将局部变量表1号位置的0加上1,结果是1存入局部变量表中
5、执行istore_1
将操作数栈中的0放入到局部变量表1的1号位置。所以最终的结果就是0了
如果是++i,结果就是1了
练习题:
java
public static void main(String[] args){
int i = 0, j = 0, k = 0;
i++;
j = j + 1;
k += 1;
}
如上的3-5行代码,哪行执行效率高?答案是3和5一样高,4要低一些
对应字节码指令如下:
java
0 iconst_0
1 istore_1
2 iconst_0
3 istore_2
4 iconst_0
5 istore_3
6 iinc 1 by 1
9 iload_2
10 iconst_1
11 iadd
12 istore_2
13 iinc 3 by 1
16 return
iconst_0
和istore_1
是将0放入操作数栈,然后将操作数栈中的数据放入到局部变量表的1号位置,即将i的初始值0放入局部变量表的1号位置
iconst_0
和istore_2
同理,将j的初始值0放入局部变量表的2号位置
iconst_0
和istore_3
同理,将k的初始值0放入局部变量表的3号位置
iinc 1 by 1
,将局部变量表1号位置的数据加1后存入,对应的是i++
iload_2、iconst_1、iadd、istore_2
,是执行j=j+1的步骤,这些步骤依次是,从局部变量表的2号位置取出j的初始值0放入操作数栈;将变量1入操作数栈;执行加法操作,将结果1放入操作数栈;将操作数栈中的1写回局部变量表2号位置
iinc 3 by 1
,将局部变量表3号位置的数据加1后存入,对应的是i++
可以看出,在加法过程中,3行和5行对应一条指令,而4行对应了好几条指令
五、类加载阶段/类的生命周期

1)加载
类加载器根据类的全限定名通过不同的渠道以二进制流的方法获取字节码信息。
不同的渠道
- 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
- 同时,Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象,包括字段、方法等信息。方便反射使用
作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。
问题,为什么创建了两块区域InstanceKlass和Class,使用一块不是节省内存空间吗?
1、InstanceKlass是由C++编写的对象,java语言不能直接操作该对象,所以JVM就在堆中创建了一个java.lang.Class包装之后的对象,我们就可以在代码中获取到;
2、出于安全性考虑。InstanceKlass中的字段要多于java.lang.Class中的字段,例如InstanceKlass中有虚方法表,这是实现多态的原理。而这些内容是不希望被程序员访问到的,就剔除掉,最终就得到了java.lang.Class中的字段,这些字段程序员是可以访问到的。而InstanceKlass程序员就访问不了了。
2)连接
验证
验证类是否符合 JVM规范,安全性检查。例如文件格式中的ca fe ba be
准备
为 static 变量分配空间,设置默认值
例如,我们定义了一个静态变量value,在连接的准备阶段,是在堆内存中为value分配了一块内存空间,给它赋予默认值0
注意
- final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
解析
将常量池中的符号引用转换为内存中的直接引用。
- 符号引用就是在字节码文件中使用编号来访问常量池中的内容。
- 直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
3)初始化
- 初始化阶段会执行静态代码块中的代码,并为静态变量赋值。
- 初始化阶段会执行字节码文件中clinit部分的字节码指令。

例如上面的源代码及其字节码。
执行流程如下

1、初始是这样的,堆中的value还是上一步连接的准备阶段赋予的默认值0
2、接着执行iconst_1将1放入操作数栈
3、接着将操作数栈中的1出栈,赋予常量池中编号为2的变量,即value变量,于是value就由0变成了1
4、接着执行静态代码块中的指令,也是将2放入操作数栈,然后将操作数栈中的2出栈,赋值给常量池中编号为2的value,于是value就是2
上述的两行代码对调,结果是什么?

最终结果是1。
我们发现一个特点:clinit方法中的执行顺序与Java中编写的顺序是一致的。
以下几种方式会导致类的初始化:
- 访问一个类的静态变量或者静态方法会触发类的初始化。注意变量是final修饰的并且等号右边是常量不会触发初始化,这是因为在连接阶段就已经确定了最终的值了。
- 调用Class.forName(String className),它底层是调用了forName0方法,有一个初始化参数,默认为true,就代表了要初始化。
- new一个该类的对象时。
- 执行Main方法的当前类。
面试题
第一题
分析:
1、程序从main方法开始执行。要执行Test1类的main方法,需要将Test1进行加载并初始化。初始化就会执行初始化方法,对应的就是static静态代码块。所以先输出D
2、接着输出A
3、接着执行new Test1(),因为Test1类在上面已经加载并初始化过了,这里就不需要进行加载和初始化了。new Test1()是调用Test1的构造方法,注意这里输出C的代码块在字节码中会出现在构造方法中,并且在构造方法之前。所以这里会打印出CBCB
clinit指令在特定情况下不会出现,比如:如下几种情况是不会进行初始化指令执行的
- 无静态代码块且无静态变量赋值语句。
- 有静态变量的声明,但是没有赋值语句。 public static int a;
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。 public static final int a = 0;
初始化阶段
- 直接访问父类的静态变量,只会触发父类的初始化,不会触发子类的初始化。
- 子类的初始化clinit调用之前,会先调用父类的clinit初始化方法。
那下面的代码呢
访问B02.a,因为a是父类中的静态变量,访问它只会触发父类的初始化,a为1
代码1
java
public class Demo1{
public static int value = 1;
static{
value = 2;
}
{
value = 3;
}
public static void main(String[] args){
System.out.println(value);
}
}
结果是2。这段代码在初始化阶段可以确定最终的值,初始化阶段是执行clinit<>方法,首先会将value赋值为1,然后调用静态代码块给value赋值为2。没有执行代码块,将value赋值为3,因为clinit<>方法没有对应的字节码信息
在上述代码基础上加一行
java
public class Demo1{
public static int value = 1;
static{
value = 2;
}
{
value = 3;
}
public static void main(String[] args){
new Demo1();
System.out.println(value);
}
}
结果是3。首先执行初始化指令,先将value赋值为1,再赋值为2。接着执行new Demo1()创建Demo1对象,会执行对象的构造方法,对应字节码是init<>方法,而代码块中给value赋值为3的字节码信息就会出现在构造方法中,即在init<>方法中,并且是在构造方法之上。先执行代码块,然后执行构造方法
六、类加载器
类加载器只用于实现类的加载动作
应用场景
名称 | 负责加载的类(下面是加载的类目录) | 说明 |
---|---|---|
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 使用C++语言编写,无法直接访问,通过System.out.print输出的类加载器名字为null,因为java程序无法获取到启动类加载器。所以类加载器名字为null就代表是启动类加载器 |
Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader(应用程序类加载器) | classpath | 上级为Extension |
自定义类加载器 | 自定义 | 上级为Application |
1)启动类加载器
加载java中最核心的类
如果我们自己写了一些类,想让启动类加载器加载,有两种方案
- 放入jre/lib下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即使放进去由于文件名不匹配的问题也不会正常地被加载
- 使用参数进行扩展。推荐,使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展
2)扩展类加载器
如果我们自己写了一些类,想让启动类加载器加载,有两种方案
- 放入/jre/lib/ext下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容
- 使用参数进行扩展。推荐,使用-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows系统):(macos/linux系统)追加上原始目录
3)双亲委派机制(高频面试题)
由于Java虚拟机中有多个类加载器,双亲委派机制的核心就是解决一个类到底由谁加载的问题。
双亲委派机制的作用:
- 保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String类,确保核心类库的完整性、安全性
- 避免重复加载。一个类只能被一个类加载器加载
双亲委派机制指的是,当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由自顶向下进行加载
1、自下而上过程
比如,类A,应用程序类加载器没有加载过,则看扩展类加载器,发现也没有加载过,就看启动类加载器,发现加载过,就返回。
2、自上而下过程

类B,先看启动类加载器,发现不在它的加载目录中,于是看扩展类加载器,发现在自己的加载目录中,于是加载成功
第二次再去加载相同的类,仍然会向上进行委派。发现应用程序类加载器没有加载过,就向上委派,发现扩展类加载器加载过就返回
向下委派加载起到了一个加载优先级的作用。一个类首先由启动类加载器加载,它加载不了的交给扩展类加载器。扩展类加载器加载不了的再交给应用程序类加载器。

比如,C类在当前程序的classpath中,从我们的经验是由应用程序类加载器加载的。加载流程是这样的,首先判断这个类是否被加载过,开始从下到上进行判断,发现三个类加载器均未加载该类;于是,从上到下进行加载,这个类不在启动类加载器和扩展类的加载范围,于是交给应用程序类加载器加载成功。
所以,双亲委派机制的流程是,从下到上依次判断这个类有没有被加载过,加载过就直接返回;如果一直到最上层的类加载器都没有加载过,就从上到下依次尝试进行加载。如果这个类在自己的加载范围,则进行加载;如果不在自己的加载范围,则交给下一个类加载器进行尝试。
问题
- 如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?启动类加载器。
- 在自己的项目在创建一个java.lang.String类,会被加载吗,能否替换掉jdk中的String类?不能,根据双亲委派机制,String类最终会被启动类加载器进行加载
如何使用代码的方式主动加载一个类?
- 使用Class.forName方法,使用当前类的类加载器去加载指定的类。
- 获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载
类加载器的细节
- 每个Java实现的类加载器中保存了一个成员变量叫"父"(Parent)类加载器,可以理解为它的上级,并不是继承关系。

- 启动类加载器使用C++编写,没有父类加载器
双亲委派机制源码
defineClass是在方法区中创建instanceKlass对象,在堆中创建Class对象
loadClass源码。要打破双亲委派机制,就需要改动该代码
java
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先查找该类是否已经被该类加载器加载过了
Class<?> c = findLoadedClass(name);
// 如果没有被加载过
if (c == null) {
long t0 = System.nanoTime();
try {
// 看是否被它的上级加载器加载过了 Extension 的上级是Bootstarp,但它显示为null
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 看是否被启动类加载器加载过
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
//捕获异常,但不做任何处理
}
if (c == null) {
// 如果还是没有找到,先让拓展类加载器调用 findClass 方法去找到该类,如果还是没找到,就抛出异常
// 然后让应用类加载器去找 classpath 下找该类
long t1 = System.nanoTime();
c = findClass(name);
// 记录时间
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4)打破双亲委派机制
重点学习前两种,第三种了解即可
以tomcat应用为例,我有两个web应用,两个应用里面有一个全限定类名完全相同的类MyServlet,但是类的内容不同。如果不打破双亲委派机制,当应用程序类加载器加载了web1应用中的MyServlet之后,尝试去加载web2应用中的MyServlet时,发现它已经被加载过了,就不会加载了,而是返回web1应用中的MyServlet,这不是我们想要的。
tomcat是如何解决这个问题的呢
tomcat是通过自定义类加载器实现的。每个应用都会有自己独立的类加载器。这些类加载器有一个特点,不走双亲委派机制了,每个类加载器单独加载自己的类。即便全限定名相同,也都会被加载成功。
a)自定义类加载器,打破双亲委派机制
java
public class BreakClassLoader1 extends ClassLoader {
private String basePath;
private final static String FILE_EXT = ".class";
public void setBasePath(String basePath) {
this.basePath = basePath;
}
private byte[] loadClassData(String name) {
try {
String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));
FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
try {
return IOUtils.toByteArray(fis);
} finally {
IOUtils.closeQuietly(fis);
}
} catch (Exception e) {
System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
return null;
}
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 文件名以java.开头,通过它的父类加载器进行加载
if(name.startsWith("java.")){
return super.loadClass(name);
}
// 自定义类加载器加载,获取到byte数组
byte[] data = loadClassData(name);
// 调用defineClass方法,在堆和方法区中创建对象
return defineClass(name, data, 0, data.length);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
BreakClassLoader1 classLoader1 = new BreakClassLoader1();
classLoader1.setBasePath("D:\\lib\\");
Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");
BreakClassLoader1 classLoader2 = new BreakClassLoader1();
classLoader2.setBasePath("D:\\lib\\");
Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");
System.out.println(clazz1 == clazz2); // false,两个不同的类加载器加载了相同全限定名的类,对应结果是不同的类
Thread.currentThread().setContextClassLoader(classLoader1);
System.out.println(Thread.currentThread().getContextClassLoader());
System.in.read();
}
}
自定义类加载器如果不手动设定parent,则它的parent默认是应用程序类加载器。对应源码如下:
问题:两个自定义类加载器加载相同限定名的类,不会冲突吗
不会冲突,在同一个JVM中,只有相同的类加载器+相同的类限定名才会被认为是同一个类。
正确的去实现一个自定义类加载器的方式是重写findClass方法,这样不会破坏双亲委派机制
b)线程上下文类加载器,打破双亲委派机制
启动类加载器加载完成DriverManager类之后,需要委派应用程序类加载器加载mysql驱动,这就违反了双亲委派机制。因为双亲委派机制是由底层向上委派,而这是向下委派
问题1:DriverManager如何知道jar包中要加载的驱动在哪呢?

其实用到了SPI机制。分为两步。
1、暴露信息。在mysql中要暴露驱动信息给DriverManager去使用。它就需要在jar中固定的文件夹META-INF/services文件夹下,创建一个文件,文件名就是驱动实现的接口,DriverManager加载的驱动都是实现了java.sql.Driver接口的。接着在文件中写上要暴露出去的接口实现类,这里就是mysql驱动实现类。
2、加载。在暴露接口实现类之后,DriverManager就可以使用类加载器加载这个实现类了。这里使用了ServiceLoad加载实现类,参数名是接口名java.sql.Driver,返回一个加载器。这个加载器就可以使用迭代器拿到当前的实现类类名,并创建对象。
问题2:SPI中是如何获取到应用程序类加载器的?
SPI中通过调用Thread.currentThread().getContextClassLoader()方法,获取到应用程序类加载器,然后将它传给load方法。
问题3:JDBC中真的打破了双亲委派机制吗?
结论不固定。
- 打破了。这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。
- 没有打破。在JDBC案例中,有两种类要被加载:DriverManager和mysql驱动中的类。DriverManager是位于rt.jar中,由启动类加载器进行加载;mysql驱动中的类位于classpath,它在加载的时候会先向上查看,看下有没有被加载,发现都没有加载过。然后从上到下依次加载,因为该类不在启动、扩展类加载器的加载路径,所以由应用程序类加载器进行加载。这个过程满足双亲委派机制的。打破双亲委派机制的唯一方法是重写loadClass方法,整个过程没有重写loadClass方法
5)使用arthas不停机解决线上问题
6)JDK9之后的类加载器
七、运行时数据区

1)程序计数器
通过程序计数器记录当前要执行的的字节码指令的地址。不会发生内存溢出
- 解释器会解释指令为机器码交给 cpu 执行,程序计数器会记录下一条指令的地址行号,这样下一次解释器会从程序计数器拿到指令然后进行解释执行。
- 多线程的环境下,如果两个线程发生了上下文切换,那么程序计数器会记录线程下一行指令的地址行号,以便于接着往下执行。
2)虚拟机栈
1、定义
- 每个线程运行需要的内存空间,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
2、栈帧的组成
- 局部变量表:局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈:操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据:帧数据主要包含动态链接、方法出口、异常表的引用。动态链接就保存了编号(对应字节码中的#数字)到运行时常量池的内存地址的映射关系。

int i = 0对应着iconst_0和istore_0这两行指令。i = 1对应着iconst_1和istore_0这两行指令。
goto 10表示未发生异常,则跳转到10号位置return;
如果有异常,异常表就生效了,异常表中的起始PC和结束PC表示捕获异常的范围,跳转PC表示发生异常后跳转的指令行号。
astore_1表示将捕获到的异常对象引用放在局部变量表的1号位置,便于我们获取异常对象,做一些处理。iconst_2和istore_0这两行指令对应着i =2。
3、java虚拟机栈默认大小
linux系统:1MB,windows系统基于操作系统默认值
4、栈内存溢出
栈帧过大、过多、或者第三方类库操作,都有可能造成栈内存溢出 java.lang.stackOverflowError ,使用 -Xss256k 指定栈内存大小!
5、问题
问题1:垃圾回收是否涉及栈内存?
不会。栈内存是方法调用产生的,方法调用结束后会弹出栈。
问题2:栈内存分配越大越好吗?
不是。因为物理内存是一定的,栈内存越大,可执行的线程数就会越少。比如物理内存是200M,一个线程占用的栈内存是1M,那么可以创建200个线程;如果给线程分配更大的内存空间2M,那么就只能创建100个线程了。
问题3:方法内的局部变量是否线程安全
是线程安全的。判断线程安全就要判断变量是否被共享,如果非共享,那么就是线程安全的。多个线程同时执行一个方法,会为每个线程分配自己的栈内存空间。执行该方法时会在栈内存空间中分配栈帧,几个内存空间是相互独立的,不存在被共享访问的情况,所以是线程安全的。
例如下面代码,多个线程同时指向此方法,每执行一次该方法就会创建该方法对应的栈帧,每个栈帧是相互独立的,互不干扰。
例如将下面的int x=0
改成static int x = 0
,此时x是由多个线程共享,就需要考虑线程安全问题了。

6)线程运行诊断
案例一:cpu 占用过多
解决方法:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程
top 命令,查看是哪个进程占用 CPU 过高
ps H -eo pid, tid(线程id), %cpu | grep 刚才通过 top 查到的进程号, 通过 ps 命令进一步查看是哪个线程占用 CPU 过高
jstack 进程 id 通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。
例子1:
1、我们在linux环境中,通过nohup指令启动jar包(nohup指的是不挂起,不加nohup,启动java程序就会卡在启动界面,加上nohup就会在后台启动了)
java
nohup java -jar demo.jar &

执行该指令后,输出的4597就是进行的PID,我们通过ps命令就能定位到该进程。同时nohup会在启动目录创建一个nohup.out文件,输出的内容就会打印在这个文件中。
2、接下来使用top命令查看CPU使用率

可以看到有一个程序占用CPU高达100%。我们如何定位问题呢?通过top命令可以看到对应的PID是4597。但是top命令只能查看到进程占用CPU的情况,无法查看到线程的占用情况。
我们通过如下命令查看
sh
ps H -eo pid,tid,%cpu
H:代表打印进程下的线程数
eo:对哪些内容感兴趣,就可以在eo后面指定。pid,tid,%cpu指我想查看进程pid、线程tid,以及它们对CPU的占有率
执行之后的结果如下,会发现打印出很多,不方便排查

我们可以在上面的基础上加上进程号的筛选
sh
ps H -eo pid,tid,%cpu |grep 4597

可以看到4612这个线程CPU占用很高
3、使用jstack进行问题定位,命令是jstack 进程id
sh
jstack 4597
它可以把这个进程中所有的java线程列出来。

可以看到有一些java虚拟机相关的线程,还有一些用户自定义的线程thread1,我们就可以在输出信息中查找对应的线程id了。
刚刚我们已经知道了,4612这个线程占用CPU高。用ps H -eo pid,tid,%cpu |grep 4597
命令输出的线程号是十进制的,jstack命令打印出来的线程号是十六进制的。所以我们需要将十进制转换为十六进制。
我们在计算器中输入4612这个线程id,可以得到对应的十六进制为1204。接下来我们就需要去查找1204这个线程号了
我们通过1204这个线程号定位到thread1,可以看到它的状态一直是RUNNABLE,这就是占用CPU高的原因。同时,我们可以看到问题代码的行号是Test.java的24行

回到java源代码,可以看到24行死循环。
完整的java源代码
java
package com.southwind;
import java.util.*;
import java.util.concurrent.BlockingQueue;
public class Test {
public static void main(String[] args) {
new Thread(null, () -> {
System.out.println("1.....");
while (true){
}
}, "thread1").start();
new Thread(null, () -> {
System.out.println("2.....");
try {
Thread.sleep(100000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "thread2").start();
new Thread(null, () -> {
System.out.println("3.....");
try {
Thread.sleep(100000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "thread3").start();
}
}
案例二:
程序运行很长时间没有结果。
这个例子中长时间没有输出结果,是由于死锁问题。

我们还是通过nohup命令执行jar包,可以看到长时间没有输出,nohup打印出来了进程id为6282.
通过jstack命令定位问题:jstack 6282
我们往下拉,会发现有一个死锁,死锁的两个线程是0和1,有问题的代码行号是16和24。

回到源代码,我们发现正好是这两行。
完整java源代码
java
package com.southwind;
public class Test2 {
static Object a = new Object();
static Object b = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (a){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (b){
System.out.println("我获得了a和b");
}
}
}).start();
new Thread(() -> {
synchronized (b){
synchronized (a){
System.out.println("我获得了a和b");
}
}
}).start();
}
}
分析:我们在这里定义了两个共享变量a和b,线程1先获取CPU对变量a进行上锁,然后休眠2秒。休眠时间CPU执行权让给线程2,线程2对变量b进行上锁,准备对a进行上锁,发现a已经被上锁了,所以只能干等着,这时CPU执行权就释放了。等到2秒到了线程1睡眠结束了,对b进行加锁,发现b已经被上锁,所以二者就只能相互等着。
3)本地方法栈
一些带有 native 关键字的方法就是需要 JAVA 去调用本地的C或者C++方法,因为 JAVA 有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务于带 native 关键字的方法。
4)堆
1)定义
Heap 堆
- 通过new关键字创建的对象都会被放在堆内存
特点
- 它是线程共享,堆内存中的对象都需要考虑线程安全问题
- 有垃圾回收机制
2)堆内存溢出
java.lang.OutofMemoryError :java heap space. 堆内存溢出
可以使用 -Xmx8m 来指定堆内存最大大小,-Xms指定堆内存最小大小。
3)堆内存诊断
- jps 工具
查看当前系统中有哪些 java 进程 - jmap 工具
查看堆内存占用情况,这个命令只能查看某一时刻的占用情况,具体指令为jmap -heap 进程id。如果要查询连续时间的占用情况,就要用到下面的jconsole - jconsole 工具
图形界面的,多功能的监测工具,可以连续监测 - jvisualvm 工具
例子1,jmap命令的使用:
有如下代码,首先输出1,然后休眠30秒,创建一个10M的byte数组,输出2休眠30秒。然后将array对堆内存的引用清除,调用System.gc()进行垃圾回收,输出3。
java
public class Test3 {
public static void main(String[] args) throws InterruptedException {
System.out.println("1...");
Thread.sleep(30000);
System.out.println("2...");
byte[] array = new byte[1024 * 1024 * 10];
Thread.sleep(30000);
System.out.println("3...");
array = null;
System.gc();
Thread.sleep(1000000L);
}
}
为了直观地看到这3步过程中每一步的堆内存占用情况,我们这样做。
首先启动应用程序,程序输出1,在输出2之前,我们执行jps命令找到这个进程的id,然后调用jmap -heap 进程id,获取到这个时刻的堆快照信息:

我们可以看到堆的Eden区域总容量是63.5MB,现在使用了5.08M。后面new byte数组是在堆的Eden区域创建的,所以我们后面重点关注这个区域的变化。
在程序输出2,没有输出3之前,再次执行jmap -heap 进程id

可以看到Eden区域的使用为16.35M,差不多多了10M的空间,就是byte数组占用了
在程序输出3之后,再次执行jmap -heap 进程id

在执行System.gc()过后Eden区域的垃圾就被回收掉了。
例子2,jconsole命令的使用:
还是上面的例子,启动程序之后,我们执行jconsole,弹出如下图形界面,我们找到对应的程序双击就可以了
程序输出1之后的堆内存占用
程序输出2之后的堆内存占用,此时因为new byte数组,所以看到有一个内存占用的激增
程序输出3之后的堆内存占用,可以看到有一个陡降,是因为垃圾回收
例子3:jvisualvm的使用。我有一个应用程序,在执行多次垃圾回收之后内存占用仍然很高
首先我们执行一下预先写好的程序,首先使用jps找到进程id
然后根据进程id,我们获取这个时刻的堆内存占用情况:jmap -heap 进程id。可以看到Eden区域占用了108M,老年代占用了122M。
此时我们执行先执行jconsole,连接我们的应用程序,然后在界面点击垃圾回收。可以看到堆内存占用确实少了,但是只少了一点点。
此时我们再使用jmap -heap 进程id获取此时的情况,发现Eden占用少了,老年代居高不下。
此时,我们执行jvisualvm命令
会出现如上界面,我们找到对应的程序类。
我们切换到【监视】窗口,点击堆Dump获取此时的快照信息,出现如下界面。
我们点击检查,查找内存占用空间最大的20个对象。点击之后出现如下界面
可以看到第一个ArrayList占用最大,我们点击它,出现如下界面
可以看到elementData占用了200M的空间。我们点击它
发现里面有200个Student对象,每个对象占用空间大约是1M。
我们就定位到问题了,ArrayList中存了200个Student对象,每个对象占用空间大约是1M,无法被垃圾回收
回到java源代码
java
public class Test3 {
public static void main(String[] args) throws InterruptedException {
ArrayList<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++) {
students.add(new Student());
}
Thread.sleep(1000000L);
}
}
class Student{
private byte[] big = new byte[1024 * 1024];
}
发现Student对象有一个属性big,它是byte数组,占用1M的空间,我们给ArrayList中存入了200个Student对象,就占用了200M空间,堆内存空间一直存在引用无法内垃圾回收。
5)方法区
1)定义
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。==它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。==尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!
方法区是一种规范,永久代和元空间是它的实现。对于HotSpot Java虚拟机,jdk1.8之前使用永久代,1.8之后使用元空间,它是操作系统直接内存的一部分。
方法区如果无法满足分配要求,也会抛出OutOfMemoryError:MetaSpace(元空间内存溢出)
方法区主要存储内容:类的基本信息、运行时常量池(保存了字节码文件中的常量池内容)
2)组成

- 在java1.8之前,永久代是方法区的实现。==从图上可以看出,它占用JVM堆内存。==它里面有Class类信息,ClassLoader类加载器、运行时常量池(它包含了StringTable)
- 在java1.8及之后,元空间是方法区的实现。==从图上可以看出,它不占用JVM内存,而是占用操作系统内存。==它里面有Class类信息,ClassLoader类加载器,字符串常量池是放在了堆内存当中。
3)方法区内存溢出
- 1.8 之后会导致元空间内存溢出
- 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小
- 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小
- 1.8 之前会导致永久代内存溢出
- 使用 -XX:MaxPermSize=8m 指定永久代内存大小
实际场景:
spring框架的AOP、mybatis的代理,用到了cglib来实现代理,即在运行期间生成实现类字节码,这就可能在运行期间导致方法区内存溢出。
4) 常量池
常量池就是给这些常量提供一个符号,对应后面的#数字。它就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
二进制字节码包含(类的基本信息,常量池,类方法定义,包含了虚拟机的指令)
首先看看常量池是什么,编译如下代码:
java
public class Test3 {
public static void main(String[] args) throws InterruptedException {
System.out.println("hello world");
}
}
生成一个字节码文件Test3.class
然后反编译,显示字节码的详细信息
java
javap -v Test3.class
java
//1、类的基本信息
Classfile /demo/target/classes/com/southwind/Test3.class
Last modified 2024-11-6; size 546 bytes
MD5 checksum c25e9438e39d97181d00417ecebce125
Compiled from "Test3.java"
public class com.southwind.Test3
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
//2、常量池
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/southwind/Test3
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/southwind/Test3;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Test3.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/southwind/Test3
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
//3、类的方法定义
{
//构造方法
public com.southwind.Test3();
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 Lcom/southwind/Test3;
//main方法
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 hello world
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;
}
SourceFile: "Test3.java"

如上图,main方法在反编译后对应了4行代码。第1行getstatic是获取一个静态变量System.out.println,怎么知道是它呢,编译器怎么知道的呢,看到后面有一个#2,代表去常量池中找#2。
可以看到#2是一个属性引用,引用了#21和#22。#21引用了#28,#28是java.lang.System。#22引用了#29和#30。#29是out,#30是java.io.PrintStream,完整组合就是(java.lang.System).out.(java.io.PrintStream)
ldc指找到一个引用地址,要去常量池中找#3,#3对应着#23,#23对应着hello world
invokevirtual执行一次虚方法调用。要找#4,#4对应了#24和#25。
#24对应了#31,#25对应了#32和#33。这样就找到了println方法,#33是println的参数

5)运行时常量池
常量池是在*.class文件中,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
6)字符串常量池
字符串常量池StringTable,用来存储在代码中定义的字符串内容。
字符串常量池和运行时常量池有什么关系?
早期设计时,字符串常量池是属于运行时常量池的一部分(运行时常量池除了可以存储字符串,还可以存储整形数字等),他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分。
- JDK7之前,运行时常量池包含字符串常量池,方法区的实现是永久代,也就是说JDK7之前,字符串常量池在永久代中(永久代是在堆中)
- JDK7,字符串常量池被从方法区拿到了堆中,运行时常量池中剩下的东西还在永久代
- JDK8及之后,方法区的实现是元空间,字符串常量池还在堆中,运行时常量池中剩下的东西在元空间
这样改动的原因:永久代垃圾回收效率很低,需要FULL GC才触发垃圾回收。而FULL GC是需要老年代的空间不足才触发。也就是说只要老年代空间不足触发了FULL GC才会一并对永久代进行垃圾回收。
1.8移到了堆中,只需要MINOR GC即可触发垃圾回收。
静态变量存储在什么位置?
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。
7)面试题
1、有如下代码
java
public class Test3 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}
我们编译之后,对字节码进行反编译:
java
Classfile /demo/target/classes/com/southwind/Test3.class
Last modified 2024-11-6; size 495 bytes
MD5 checksum b03b7c145855f4060d709cca71eb2dd7
Compiled from "Test3.java"
public class com.southwind.Test3
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#24 // java/lang/Object."<init>":()V
#2 = String #25 // a
#3 = String #26 // b
#4 = String #27 // ab
#5 = Class #28 // com/southwind/Test3
#6 = Class #29 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/southwind/Test3;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 s1
#19 = Utf8 Ljava/lang/String;
#20 = Utf8 s2
#21 = Utf8 s3
#22 = Utf8 SourceFile
#23 = Utf8 Test3.java
#24 = NameAndType #7:#8 // "<init>":()V
#25 = Utf8 a
#26 = Utf8 b
#27 = Utf8 ab
#28 = Utf8 com/southwind/Test3
#29 = Utf8 java/lang/Object
{
public com.southwind.Test3();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/southwind/Test3;
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
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
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 args [Ljava/lang/String;
3 7 1 s1 Ljava/lang/String;
6 4 2 s2 Ljava/lang/String;
9 1 3 s3 Ljava/lang/String;
}
SourceFile: "Test3.java"
找到main方法,ldc #2代表加载#2中的内容,astore_1表示将#2中的内容存入到局部变量表的1号位置,其他命令类似。
当该类被加载以后,它的常量池信息就会放入运行时常量池。加载之后s1、s2和s3还没有成为对象a、b、ab,它还是引用常量池中的符号,还没有变为java中的字符串对象。等执行到引用它的那一行代码时,变成java中的字符串对象。
例如执行到String s1 = "a"就会成为java中的字符串a,同时会准备好一块空间StringTable,它底层是一个hashTable,刚开始是空的,然后将a作为key查找里面有没有,发现没有就把a放入StringTable中。后面的代码类似
注意,每一个字符串不是事先就放入StringTable串池中,而是用到了这行代码才放入。
2、字符串变量拼接
java
public class Test3 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
}
}
先编译然后反编译:
java
Classfile /demo/target/classes/com/southwind/Test3.class
Last modified 2024-11-6; size 679 bytes
MD5 checksum 5abf06fa3974525dcf9c957d8b32f62f
Compiled from "Test3.java"
public class com.southwind.Test3
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #10.#29 // java/lang/Object."<init>":()V
#2 = String #30 // a
#3 = String #31 // b
#4 = String #32 // ab
#5 = Class #33 // java/lang/StringBuilder
#6 = Methodref #5.#29 // java/lang/StringBuilder."<init>":()V
#7 = Methodref #5.#34 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #5.#35 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = Class #36 // com/southwind/Test3
#10 = Class #37 // java/lang/Object
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 Lcom/southwind/Test3;
#18 = Utf8 main
#19 = Utf8 ([Ljava/lang/String;)V
#20 = Utf8 args
#21 = Utf8 [Ljava/lang/String;
#22 = Utf8 s1
#23 = Utf8 Ljava/lang/String;
#24 = Utf8 s2
#25 = Utf8 s3
#26 = Utf8 s4
#27 = Utf8 SourceFile
#28 = Utf8 Test3.java
#29 = NameAndType #11:#12 // "<init>":()V
#30 = Utf8 a
#31 = Utf8 b
#32 = Utf8 ab
#33 = Utf8 java/lang/StringBuilder
#34 = NameAndType #38:#39 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#35 = NameAndType #40:#41 // toString:()Ljava/lang/String;
#36 = Utf8 com/southwind/Test3
#37 = Utf8 java/lang/Object
#38 = Utf8 append
#39 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#40 = Utf8 toString
#41 = Utf8 ()Ljava/lang/String;
{
public com.southwind.Test3();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/southwind/Test3;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, 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/String;
27: astore 4
29: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 9
line 9: 29
LocalVariableTable:
Start Length Slot Name Signature
0 30 0 args [Ljava/lang/String;
3 27 1 s1 Ljava/lang/String;
6 24 2 s2 Ljava/lang/String;
9 21 3 s3 Ljava/lang/String;
29 1 4 s4 Ljava/lang/String;
}
SourceFile: "Test3.java"
String s4 = s1 + s2;这一行代码对应的是如下代码
首先,它调用了new StringBuilder()创建了一个StringBuilder对象,然后aload_1从局部变量表中取出变量1即s1,然后调用StringBuilder的append方法将s1加入到StringBuilder。然后调用aload_2从局部变量表中取出变量2即s2,然后调用StringBuilder的append方法将s2加入到StringBuilder。借助调用StringBuilder的toString方法转换回字符串。StringBuilder的toString方法底层还是new String新创建了一个字符串。
3、代码
java
public class Test3 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
}
}
结果为false,s3在串池中,s4是通过StringBuilder的toString方法底层还是new String新创建了一个字符串。
4、代码
和上一个例子不同,上面的s4是由两个变量s1和s2拼接,本例子中的s5是由两个常量拼接
java
public class Test3 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
}
}
编译之后反编译:
java
Classfile /demo/target/classes/com/southwind/Test3.class
Last modified 2024-11-6; size 702 bytes
MD5 checksum f8a8b72d89125f99f6035a5778e1d9db
Compiled from "Test3.java"
public class com.southwind.Test3
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #10.#30 // java/lang/Object."<init>":()V
#2 = String #31 // a
#3 = String #32 // b
#4 = String #33 // ab
#5 = Class #34 // java/lang/StringBuilder
#6 = Methodref #5.#30 // java/lang/StringBuilder."<init>":()V
#7 = Methodref #5.#35 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #5.#36 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = Class #37 // com/southwind/Test3
#10 = Class #38 // java/lang/Object
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 Lcom/southwind/Test3;
#18 = Utf8 main
#19 = Utf8 ([Ljava/lang/String;)V
#20 = Utf8 args
#21 = Utf8 [Ljava/lang/String;
#22 = Utf8 s1
#23 = Utf8 Ljava/lang/String;
#24 = Utf8 s2
#25 = Utf8 s3
#26 = Utf8 s4
#27 = Utf8 s5
#28 = Utf8 SourceFile
#29 = Utf8 Test3.java
#30 = NameAndType #11:#12 // "<init>":()V
#31 = Utf8 a
#32 = Utf8 b
#33 = Utf8 ab
#34 = Utf8 java/lang/StringBuilder
#35 = NameAndType #39:#40 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#36 = NameAndType #41:#42 // toString:()Ljava/lang/String;
#37 = Utf8 com/southwind/Test3
#38 = Utf8 java/lang/Object
#39 = Utf8 append
#40 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#41 = Utf8 toString
#42 = Utf8 ()Ljava/lang/String;
{
public com.southwind.Test3();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/southwind/Test3;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
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/String;
27: astore 4
29: ldc #4 // String ab
31: astore 5
33: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 9
line 9: 29
line 10: 33
LocalVariableTable:
Start Length Slot Name Signature
0 34 0 args [Ljava/lang/String;
3 31 1 s1 Ljava/lang/String;
6 28 2 s2 Ljava/lang/String;
9 25 3 s3 Ljava/lang/String;
29 5 4 s4 Ljava/lang/String;
33 1 5 s5 Ljava/lang/String;
}
SourceFile: "Test3.java"
新加的String s5 = "a" + "b";对应下面两行代码。可以看到它是直接去常量池中找ab这个对象,和s3是完全一样的,可以猜测出来s3和s5相等。碰到s3时,串池在没有ab则往串池中加入ab;碰到s5,发现串池中有了ab,就沿用。

java
public class Test3 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
System.out.println(s3 == s5); //true
}
}
底层是javac在编译期间的优化,s5是由a和b拼接而成,结果在编译期间就可以确定为ab
5、字符串延迟加载
java
public class Test3 {
public static void main(String[] args) {
System.out.println();
System.out.println("1"); //断点
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("0");
System.out.println("1"); //断点
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("0");
}
}
如上代码,我们在输出两个1的位置打断点。在IDEA中使用memory查看String字符串个数的变化。
点击load classes

可以看到此时有2126个字符串。
往后放一行,字符串有2127个。这就证明了字符串是延迟加载的。
执行完第一个输出1-0后,字符串有2126+10=2136个
继续放下执行发现字符串数量不变了,因为串池中都有这些字符串了。
6、StringTable特性
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
7、intern(jdk1.7及以后)
java
public class Test3 {
public static void main(String[] args) {
String s = new String("a") + new String("b");
}
}
上述代码,在执行new String("a")时,会检查串池中没有a,于是在串池中创建a,并且在堆内存中创建a;
同理在执行new String("b")时,会检查串池中没有b,于是在串池中创建b,并且在堆内存中创建b。
接着借助StringBuilder进行字符串拼接,得到ab,ab是只存在于堆中的,并没有放入串池。
可以调用s.intern 方法,将该字符串对象s尝试放入到串池中。如果串池中有该字符串对象,则不放入;否则放入该字符串。不管串池中有没有该字符串,都会将串池中的字符串返回(针对jdk1.7及以后版本)
java
public class Test3 {
public static void main(String[] args) {
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == "ab"); //true
}
}
上述代码,s2是返回串池中的ab,然后s2 == "ab",获取"ab"字符串的时候会先去串池中找,发现有了就复用,所以结果是true
java
public class Test3 {
public static void main(String[] args) {
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s == s2); //true
}
}
居然发现结果是true,一直以为结果是false。s引用的是堆中的ab,堆中的s和串池中的"ab"肯定不相等
原因是这样的,执行了s.intern()就会将s对象放入串池,s就变成了串池中的ab,所以s和"ab"是相等的
java
public class Test3 {
public static void main(String[] args) {
String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == x); //true
System.out.println(s == x); //false
}
}
执行String x = "ab";会在串池中创建ab
执行String s = new String("a") + new String("b");会在串池中创建a,并且在堆中创建a;在串池中创建b,并且在堆中创建b;然后在堆中创建ab。
执行String s2 = s.intern();尝试将ab放入串池中。发现串池中有了,没有放入成功,所以s还是引用堆中的ab,s2是intern返回的串池中的ab
s2和x都是串池中的,结果相等;s是堆中的,x是串池中的,结果是false
8、intern(jdk1.6)
调用intern 方法,将该字符串对象尝试放入到串池中。如果串池中有该字符串对象,则不放入;如果没有,则会把该对象复制出来一个新对象,将新对象放入串池中,并把串池中的新对象返回。
java
public class Test3 {
public static void main(String[] args) {
String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == x); //true
System.out.println(s == x); //false
}
}
注意是在jdk1.6环境执行的
执行String x = "ab";会在串池中创建ab
执行String s = new String("a") + new String("b");会在串池中创建a,并且在堆中创建a;在串池中创建b,并且在堆中创建b;然后在堆中创建ab。
执行String s2 = s.intern();尝试将ab放入串池中。发现串池中有了,没有放入成功,所以s还是引用堆中的ab,s2是intern返回的串池中的ab
s2和x都是串池中的,结果相等;s是堆中的,x是串池中的,结果是false
java
public class Test3 {
public static void main(String[] args) {
String s = new String("a") + new String("b");
String s2 = s.intern();
String x = "ab";
System.out.println(s2 == x); //true
System.out.println(s == x); //false
}
}
注意是在jdk1.6环境执行的
执行String s = new String("a") + new String("b");会在串池中创建a,并且在堆中创建a;在串池中创建b,并且在堆中创建b;然后在堆中创建ab。
执行String s2 = s.intern();尝试将ab放入串池中。发现串池中没有,没有则将ab复制一份放入串池,所以s还是引用堆中的ab,s2是intern返回的串池中的ab
执行String x = "ab";发现串池中有了ab,则复用
s2和x都是串池中的,结果相等;s是堆中的,x是串池中的,结果是false
如果上述在jdk1.8环境执行,则返回两个true
9、代码
java
public class Test3 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
System.out.println(x1 == x2);
}
}
在jdk1.6和jdk1.8都是输出
false
true
true
false
java
String x2 = new String("c") + new String("d");
x2.intern();
String x1 = "cd";
System.out.println(x1 == x2);
上述代码,在jdk1.6输出为false,在jdk1.8输出为true。
10、JVM在JDK6-8之间在内存区域上有什么不同
==方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。==Hotspot设计如下:
- JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
- JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。也可以手动设置最大大小。
使用元空间替换永久代的原因:
1、提高内存上限:元空间使用的是操作系统内存,而不是JVM内存。如果不设置上限,只要不超过操作系统内存上限,就可以持续分配。而永久代在堆中,可使用的内存上限是有限的。所以使用元空间可以有效减少OOM情况的出现。
2、优化垃圾回收的策略:永久代在堆上,垃圾回收机制一般使用老年代的垃圾回收方式,不够灵活。使用元空间之后单独设计了一套适合方法区的垃圾回收机制。
字符串常量池的位置
- JDK7之前,运行时常量池包含字符串常量池,方法区的实现是永久代,也就是说JDK7之前,字符串常量池在永久代中(永久代是在堆中)
- JDK7,字符串常量池被从方法区拿到了堆中,运行时常量池中剩下的东西还在永久代
- JDK8及之后,方法区的实现是元空间,字符串常量池还在堆中,运行时常量池中剩下的东西在元空间
字符串常量池从方法区移动到堆的原因:
1、垃圾回收优化:字符串常量池的回收逻辑和对象的回收逻辑类似,内存不足的情况下,如果字符串常量池中的常量不被使用就可以被回收;方法区中的类的元信息回收逻辑更复杂一些。移动到堆之后,就可以利用对象的垃圾回收器,对字符串常量池进行回收。
2、让方法区大小更可控:一般在项目中,类的元信息不会占用特别大的空间,所以会给方法区设置一个比较小的上限。如果字符串常量池在方法区中,会让方法区的空间大小变得不可控。
3、intern方法的优化:JDK6版本中intern () 方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中。JDK7及之后版本中由于字符串常量池在堆上,就可以进行优化:字符串保存在堆上,把字符串的引用放入字符串常量池,减少了复制的操作。
8) StringTable 垃圾回收
StringTable中的对象如果在堆内存紧张的时候也会被垃圾回收。不是说它是串池就不进行垃圾回收了。
9)StringTable 性能调优
- 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间
java
-XX:StringTableSize=桶个数(最少设置为 1009 以上)
- 考虑是否需要将字符串对象入池
可以通过 intern 方法减少重复入池,减少堆内存的占用
6)直接内存
1)定义
Direct Memory。它是操作系统内存,不属于JVM内存。
- 常见于 NIO 操作时,用于数据缓冲区
- 因为是操作系统内存,分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
- 会产生内存溢出问题,因为它也是内存
2)使用直接内存的好处
文件读写流程:
因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法native进行操作,然后读取磁盘文件,会在系统内存中创建一个系统缓冲区,将数据读到系统缓冲区, 然后在将系统缓冲区数据,复制到 java 堆内存中。缺点是数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制,效率较低。
使用了 DirectBuffer 文件读取流程

直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。
3)直接内存回收原理
- 使用了Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法
- ByteBuffer的内部实现类,使用了Cleaner(虚引用)来监测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存
java
public class Code_06_DirectMemoryTest {
public static int _1GB = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
// method();
method1();
}
// 演示 直接内存 是被 unsafe 创建与回收
private static void method1() throws IOException, NoSuchFieldException, IllegalAccessException {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe)field.get(Unsafe.class);
long base = unsafe.allocateMemory(_1GB);
unsafe.setMemory(base,_1GB, (byte)0);
System.in.read();
unsafe.freeMemory(base);
System.in.read();
}
// 演示 直接内存被 释放
private static void method() throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc(); // 手动 gc
System.in.read();
}
}
直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过unsafe.freeMemory 来手动释放。
第一步:allocateDirect 的实现
java
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
底层是创建了一个 DirectByteBuffer 对象。
第二步:DirectByteBuffer 类
java
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为虚引用的实际对象, 第二个参数是一个回调,实现了 runnable 接口,run 方法中通过 unsafe 释放内存。
att = null;
}
这里调用了一个 Cleaner 的 create 方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer )被回收以后,就会调用 Cleaner 的 clean 方法,来清除直接内存中占用的内存。
java
public void clean() {
if (remove(this)) {
try {
// 都用函数的 run 方法, 释放内存
this.thunk.run();
} 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;
}
});
}
}
}
可以看到关键的一行代码, this.thunk.run(),thunk 是 Runnable 对象。run 方法就是回调 Deallocator 中的 run 方法,
java
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 释放内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
八、垃圾回收
内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。
1、方法区回收
方法区中能回收的内容主要就是不再使用的类。
判定一个类可以被卸载。需要同时满足下面三个条件:
- 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用
手动触发垃圾回收
如果需要手动触发垃圾回收,可以调用System.gc()方法。
调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。
2、堆回收
1)如何何判断堆上的对象可以回收
a)引用计数法
当一个对象被引用时,就当引用对象的值加一,当值为 0 时,就表示该对象不被引用,可以被垃圾收集器回收。
这个引用计数法听起来不错,但是有一个弊端,如下图所示,循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。

b)可达性分析算法
- JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象
- 扫描堆中的对象,看能否沿着 GC Root 对象为起点的引用链找到该对象,如果找不到,则表示可以回收
- 可以作为 GC Root 的对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的Native方法)引用的对象
虚拟机栈(栈帧中的本地变量表)中引用的对象。
如上图,我们创建了A的对象a1,B的对象b1,然后让a1的b属性关联b1,b1的a属性关联a1。此时我们分析下:
GC ROOT是位于堆内存中的,我们沿着GC ROOT可以找到栈内存中的属性,通过栈内存中的属性又可以找到堆内存中的实例对象。因为以GC ROOT为起点可以找到这两个对象,所以它们就不能被垃圾回收。
当执行到a=null,b=null,无法以GC ROOT为起点找到这两个对象,所以它们就可以被垃圾回收。
2)五种引用
为了简化,下图中实线代表强引用,虚线代表非强引用。

- 强引用
只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。如上图的A1对象,如果B、C对象的强引用断开,则该对象才能被垃圾回收;否则其中一个强引用存在,该对象不能被垃圾回收。 - 软引用(SoftReference)
仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象。软引用出现的条件:垃圾回收+内存不足
可以配合引用队列来释放软引用自身 - 弱引用(WeakReference)
仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。弱引用出现的条件:垃圾回收
可以配合引用队列来释放弱引用自身。弱引用主要在ThreadLocal中使用 - 虚引用(PhantomReference)
必须配合引用队列使用。例如,我们通过ByteBuffer的方式分配了直接内存,在回收的时候,通过调用GC将ByteBuffer对象进行回收,但是由ByteBuffer创建的直接内存,Java程序是无法进行操作的。所以在将ByteBuffer对象进行回收同时,会将ByteBuffer对象的虚引用放入引用队列,由 Reference Handler 线程调用虚引用相关方法释放直接内存 - 终结器引用(FinalReference)
无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。
问题:ThreadLocal为什么使用弱引用?
- 当threadlocal对象不再使用时,使用弱引用可以让对象被回收;因为仅有弱引用没有强引用的情况下,对象是可以被回收的。
- 弱引用并没有完全解决掉对象回收的问题,Entry对象和value值无法被回收,所以合理的做法是手动调用remove方法进行回收,然后再将threadlocal对象的强引用解除
value存的是对象数据,上面的例子中是存放用户数据
threadlocal=null,就是将上图的强引用去掉了。
3、垃圾回收算法
1)标记清除
定义:Mark Sweep
- 速度较快。只需要将垃圾对象对应的内存地址放入空闲内存区域即可
- 会产生内存碎片
清除过程不是将垃圾对象对应内存的内容清除,而是将它放到一个空闲内存区域,供下次内存分配。
2)标记整理
Mark Compact
- 速度慢
- 没有内存碎片
3)复制
Copy。复制算法是将内存空间分为大小相等的两块区域FROM和TO,对FROM中的对象进行一次标记,标记处垃圾对象和非垃圾对象。然后将FROM区域中的非垃圾对象复制到TO区域,并进行一次整理使得地址连续。复制结束后,FROM区域就都是垃圾对象了,一次性将FROM区域进行清空。最后将FROM和TO进行交换,即FROM变为TO,TO变为FROM。
- 不会有内存碎片
- 需要占用两倍内存空间
4、分代垃圾回收
新生代:老年代=1:2,新生代中,eden:from:to=8:1:1

-
新创建的对象首先分配在 eden 区
-
当eden区满了,触发一次Minor GC,通过可达性分析算法区分垃圾和非垃圾对象,然后通过复制算法将非垃圾对象放入幸存区To中,同时将这些对象的寿命+1变为1,表示经过一次垃圾对象不死。最后将eden区域中的所有对象清空
-
然后将幸存区FROM和TO交换位置,保证TO区域是空的
-
当再次往eden区域放入对象满了,会触发minor gc对eden和幸存区进行垃圾回收
-
若eden区域有非垃圾对象,则放入幸存区TO;所若幸存区FROM中有非垃圾对象,则放入幸存区TO中,同时将寿命加1
-
新生代空间不足时,触发 minor gc ,eden 区 和 from 区存活的对象使用 - copy 复制到 to 中,存活的对象年龄加一,然后交换 from to
-
minor gc 会引发 stop the world,暂停其他线程,等垃圾回收结束后,恢复用户线程运行。minor gc时间非常短,因为新生代大部分对象都是垃圾
-
当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(4bit)
-
当老年代空间不足时,会先触发 minor gc,如果空间仍然不足,那么就触发 full fc ,停止的时间更长!
-
如果触发full gc后仍然内存不足,则触发OutOfMemoryError
1)相关 JVM 参数
含义 | 参数 |
---|---|
堆初始大小 | -Xms |
堆最大大小 | -Xmx 或 -XX:MaxHeapSize=size |
新生代大小 | -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size ) |
幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy |
幸存区比例 | -XX:SurvivorRatio=ratio |
晋升阈值 | -XX:MaxTenuringThreshold=threshold |
晋升详情 | -XX:+PrintTenuringDistribution |
GC详情 | -XX:+PrintGCDetails -verbose:gc |
FullGC 前 MinorGC | -XX:+ScavengeBeforeFullGC |
2)GC 分析
java
public class Test3 {
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) {
}
}
-Xms20m:堆初始大小
-Xmx20m:堆最大大小
-Xmn10m:新生代大小
-XX:+UseSerialGC:指定垃圾收集器
-XX:+PrintGCDetails -verbose:gc 打印GC日志
执行上述代码,控制台输出:
java
Heap
def new generation total 9216K, used 1893K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 23% used [0x00000000fec00000, 0x00000000fedd9690, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3158K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 344K, capacity 388K, committed 512K, reserved 1048576K
def new generation:新生代
tenured generation:老年代。
从上面的输出,可以看到eden区域一共8M,已经占用了23%。我们的代码是一个空函数,eden区域还有占用,是因为启动java程序会用到一些系统变量占用了该区域
java
public class Test3 {
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) {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
}
}
在此基础上添加代码,往ArrayList中放入7M的对象。从上面打印从结果eden区域一共8M,已经占用了23%。新加入的这7M对象是放不下的,会触发GC。
运行代码,控制台输出如下:
java
[GC (Allocation Failure) [DefNew: 1729K->590K(9216K), 0.0012122 secs] 1729K->590K(19456K), 0.0012454 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 8168K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 92% used [0x00000000fec00000, 0x00000000ff366848, 0x00000000ff400000)
from space 1024K, 57% used [0x00000000ff500000, 0x00000000ff593a38, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3212K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 353K, capacity 388K, committed 512K, reserved 1048576K
GC (Allocation Failure):表示minor gc,如果是full gc,就会打印出full gc
DefNew: 1729K-\>590K(9216K), 0.0012122 secs\]:表示新生代垃圾回收情况,1729k表示回收前内存占用,590K表示回收后内存占用,9216k表示这个区域总大小,后面的时间表示垃圾回收耗时。
1729K-\>590K(19456K), 0.0012454 secs\] :表示整个堆垃圾回收情况
eden space 8192K, 92% used:表示eden区域使用,92%乘以8192K差不多是7M的对象,可以看出7M对象是放入了eden区域了
from space 1024K, 57% used:表示from区域使用,是垃圾回收将eden区域中的对象移入到from区域
```java
public class Test3 {
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) {
ArrayList
- 在 JVM 内部,使用了不同的字符串标
优点与缺点
- 节省了大量内存
- 新生代回收时间略微增加,导致略微多占用 CPU
开关,默认打开
java
-XX:+UseStringDeduplication
9、JDK 8u40 并发标记类卸载
在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
10、JDK 8u60 回收巨型对象
- 一个对象大于region的一半时,就称为巨型对象。下图在H表示巨型对象。
- G1不会对巨型对象进行拷贝
- 回收时被优先考虑
- 老年代引用为0的巨型对象就可以在新生代垃圾回收时处理掉
6、垃圾回收调优
1)调优领域
- 内存
- 锁竞争
- cpu 占用
- io
- gc
2)确定目标
低延迟/高吞吐量? 选择合适的GC
- CMS G1 ZGC
- ParallelGC
3)最快的 GC是不发生GC
首先排除减少因为自身编写的代码而引发的内存问题
- 查看 Full GC 前后的内存占用,考虑以下几个问题
- 数据是不是太多?
resultSet = statement.executeQuery("select * from 大表 limit n") - 数据表示是否太臃肿
- 对象图
- 对象大小 16 Integer 24 int 4
- 是否存在内存泄漏
- 数据是不是太多?
4)新生代调优
- 新生代的特点
- 所有的 new 操作分配内存都是非常廉价的
- TLAB thread-lcoal allocation buffer
- 死亡对象回收零代价
- 大部分对象用过即死(朝生夕死)
- Minor GC 所用时间远小于 Full GC
- 所有的 new 操作分配内存都是非常廉价的
- 新生代内存越大越好么?
- 不是
- 新生代内存太小:频繁触发 Minor GC ,会 STW ,会使得吞吐量下降
- 新生代内存太大:老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长
- 新生代内存设置为内容纳[并发量*(请求-响应)]的数据为宜
- 幸存区需要能够保存 当前活跃对象+需要晋升的对象
- 晋升阈值配置得当,让长时间存活的对象尽快晋升
- 不是
5)老年代调优
以 CMS 为例:
- CMS 的老年代内存越大越好
- 先尝试不做调优,如果没有 Full GC 那么已经,否者先尝试调优新生代。
- 观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
九、Java 内存模型(JMM)
JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
1 原子性
两个线程同时操作共享变量,一个线程对变量加1执行一万次,一个线程对变量减1执行一万次,结果不一定是0.
这是因为java对静态变量的自增、自减不是原子操作。就可能导致CPU交错执行
例如对于i++而言,实际的字节码指令为:
java
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i
而对应 i-- 也是类似:
java
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换:
把内存分为主内存和工作内存。
解决方法
java
synchronized( 对象 ) {
要作为原子操作代码
}
2 可见性
退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
java
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(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile 变量的修改对另一个线程可见, 不能保证原子性
3 有序性
指令重排,是 JIT 编译器在运行时的一些优化。
解决方法
volatile 修饰的变量,可以禁用指令重排
多线程下『指令重排』会影响正确性,例如著名的 double-checked locking 模式实现单例
java
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
happens-before
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
4 CAS 与 原子类
CAS 即 Compare and Swap ,又称为无锁并发,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:
java
// 需要不断尝试
while(true) {
int 旧值 = 共享变量 ; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if( compareAndSwap ( 旧值, 结果 )) {
// 成功,退出循环
}
}
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
原子类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。
5 synchronized优化
轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。这就好比:
学生(线程 A)用课本(轻量级锁)占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。
如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
重量锁与自旋
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
- Java 7 之后不能控制是否开启自旋功能
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS操作。Java 6 中引入了偏向锁,来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的 hashCode 也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
6、其它优化
减少上锁时间
同步代码块中尽量短
缩小锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
- ConcurrentHashMap
- LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
- LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
锁粗化
多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次
java
new StringBuffer().append("a").append("b").append("c");
锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
读写分离
CopyOnWriteArrayList
ConyOnWriteSet
十、实用工具
阿里arthas
启动arthas
java
java -jar arthas-boot.jar
监控界面
sh
dashboard
导出类的字节码信息
sh
dump -d 目录 类全限定名
通过字节码得到类的java源代码
sh
jad 类全限定名
实战篇
一、内存调优
1、内存泄漏和内存溢出
-
内存泄漏(memory leak):在Java中如果不再使用一个对象,但是该对象依然在GC ROOT的引用链上,这个对象就不会被垃圾回收器回收,这种情况就称之为内存泄漏。
-
内存泄漏绝大多数情况都是由堆内存泄漏引起的,所以后续没有特别说明则讨论的都是堆内存泄漏
-
少量的内存泄漏可以容忍,但是如果发生持续的内存泄漏,就像滚雪球雪球越滚越大,不管有多大的内存迟早会被消耗完,最终导致的结果就是内存溢出。但是产生内存溢出并不是只有内存泄漏这一种原因。
2、解决内存溢出的思路

2.1 top命令
top命令是linux下用来查看系统信息的一个命令,它提供给我们去实时地去查看系统的资源,比如执行时的进程、线程和系统参数等信息。
top命令比较适合做一次初步的筛查,判断哪个进程引起CPU、内存利用率高
- load average:系统负载,如上图的三个值,0.02、0.10、0.06,分别代表系统过去一分钟、五分钟、十五分钟的系统负载,2%、10%、6%。它是值是CPU忙碌时间除以CPU总时间,值越大CPU越忙。另外,负载的值可能会超过1,这是因为存在一些等到的线程,这些也会被计算到负载当中
- Mem:内存使用情况,total总内存大小,free空闲内存,used已使用内存,buff/cache缓存
- PID:进程id
- RES:常驻内存,代表当前整个进程使用了多少内存,展示的数值包括了共享内存SHR。所以进程使用的内存=RES - SHR
- SHR:共享内存。例如使用的第三方依赖库,只需要加载一次就可以在多个进程间实现共享。这部分内存是不应该被计算到当前进程的内存中的
- %CPU:对CPU的使用率,1.0%就是使用了1%的时间。这个值如果长时间比较高,就需要注意是否存在大量请求,或者代码中存在死循环
- #MEM:进程使用的内存占用实际物理内存的百分比
- TIME+:当前进程启动以来累计消耗CPU的时间
- COMMAND:进程对应的命令
2.2 VisualVM
VisualVM是多功能合一的Java故障排除工具并且他是一款可视化工具,整合了命令行 JDK 工具和轻量级分析功能,功能非常强大。
2.3 arthas
3、产生内存溢出的原因
4、案例
原理篇
一、对象在堆上是如何存储的
1、对象在堆中的内存布局

- 对象分为普通对象和数组对象,数组对象就是数组了。对象内容包括对象头、对象数据。对象头中存放基本信息/元信息。对象数据是真实保存数据的地方。
- Mark Word标记字段:保存锁、垃圾回收等特定功能的信息(了解即可)
- Klass pointer元数组的指针:在堆中创建对象的同时,会在方法区中创建instanceKlass对象,里面包含了对象、方法、虚方法表等。元数组的指针就指向方法区中的instanceKlass对象
- 如果是数组对象,在对象头中还包含数组的长度
- 对象数据包含了对象的具体字段,还有一块是内存对齐填充。它不用于逻辑处理,仅用于将当前对象长度进行归整。
指针压缩:压缩的内容包括klass pointer和String引用数据类型
左边是不开启指针压缩,内存是一个格子一个字节,一个8字节的对象占用8个格子的空间大小,那么它的内存地址就是8;
右边是开启指针压缩,内存是一个格子八个字节,一个8字节的对象占用1个格子的空间大小,那么它的内存地址就是2;

比如一个4字节的对象占用了8字节空间,需要进行填充,填充到8字节
- 字段重排序,保证字段OFFSET能被类型长度整除。比如,LONG类型的数据占用了8字节,但是它的OFFSET是12,就会有字段重排序,比如说有一个四字节的INT类数据,将它放在LONG类型前面,这样LONG类型的OFFSET就是16了
- 内存对象填充:保证对象能被8字节整除
2、方法调用的原理

如上图,main方法的栈帧先入栈,它调用了study方法,main方法的字节码如图,invokestatic就是指向静态方法调用。
在JVM中,一共有五个字节码指令可以执行方法调用:
- 1、invokestatic:调用静态方法
- 2、invokespecial: 调用对象的private方法、构造方法,以及使用 super 关键字调用父类实例的方法、构造方法,
以及所实现接口的默认方法。 - 3、invokevirtual:调用对象的非private方法。
- 4、invokeinterface:调用接口对象的方法。
- 5、invokedynamic:用于调用动态方法,主要应用于lambda表达式中,机制极为复杂了解即可。
Invoke方法的核心作用就是找到字节码指令并执行。
- 如上图,Object类有4个方法,就在数组中存储四个方法的地址。
- Animal类继承自Object类,重写了toString方法,那么toString()方法的地址替换为Animal的toString方法地址,其他方法的地址还是使用Object
- Cat继承自Animal类,重写eat()方法

cat对象对象头的klass pointer指向了方法区中的instanceKlass对象,获得虚方法表。根据虚方法表找到对应的地方,获取方法的地址,调用方法。
3、异常捕获的原理
4、JIT即时编译器
JIT即时编译器是一项用来提升应用程序代码执行效率的技术。字节码指令被 Java 虚拟机解释执行,如果有一些指令执行频率高,称之为热点代码,这些字节码指令则被JIT即时编译器编译成机器码同时进行一些优化,最后保存在内存中,将来执行时直接读取就可以运行在计算机硬件上了。