# 概述
JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
其中虚拟机栈、程序计数器、本地方法栈是线程私有的,也就是说每个线程都会维护自己的一份虚拟机栈、程序计数器、本地方法栈,而方法区和堆是所有线程共享的。
线程共享的可以看做主存,线程私有的可以看做工作内存,工作内存是一个虚拟模型,实际系统中并没有实际物理内存与其直接的对应,Java官方文档中也没有“工作内存”这个概念,这个工作内存是各种CPU架构支持的内存模型跟编译器的各种优化而产生的一个效果,底层对应着CPU寄存器、缓存、CPU指令优化等。
# 程序计数器
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,这个计数器值则为空。
举例
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
}
2
3
4
5
6
7
将代码进行编译成字节码文件,我们再次查看 ,发现在字节码的左边有一个行号标识,它其实就是指令地址,用于指向当前执行到哪里。
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
2
3
4
5
6
7
8
9
通过PC寄存器,我们就可以知道当前程序执行到哪一步了
使用PC寄存器存储字节码指令地址有什么用?
在Jvm中是多线程环境,多个线程并发执行,CPU会不停切换各个线程,切换回来后,就需要知道接着从哪个地方开始。
# 虚拟机栈
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。也就是说,方法的调用就对应栈帧的进栈,方法的返回就对应栈帧的出栈。
栈中可能出现的异常
Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 outofMemoryError 异常。
可通过如下参数设置线程的最大栈空间大小
-Xss1m
-Xss1k
2
# 栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(方法正常退出或者异常退出的定义)
- 一些附加信息
# 局部变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及return Address类型。
成员变量(类变量,实例变量)、局部变量初始化时的区别
- 类变量:链接的prepare阶段,给类变量默认赋值,初始化阶段给类变量显式赋值,即静态代码块,类变量存在方法区
- 实例变量:随着对象创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量:在使用前必须进行显式赋值,不会默认赋值。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
# 操作数栈
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间,当前方法调用另一个方法的返回值就压在操作数栈上。
# 动态链接(指向运行时常量池的方法引用)
动态链接指向运行时常量池中该栈帧所属方法的引用,在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Reference)保存在class文件的常量池里。
比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
为什么需要运行时常量池?
因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,节省了空间
常量池的作用:就是为了提供一些符号和常量,便于指令的识别
# 方法返回地址
存放调用该方法的PC寄存器的值,如果有返回值,则将返回值压入调用者栈帧的操作数栈。
方法中定义的局部变量是否线程安全?
**
* 面试题
* 方法中定义局部变量是否线程安全?具体情况具体分析
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的
* 如果有多个线程操作,则此数据是共享数据,如果不考虑共享机制,则为线程不安全
* @author: 陌溪
* @create: 2020-07-06-16:08
*/
public class StringBuilderTest {
// s1的声明方式是线程安全的
public static void method01() {
// 线程内部创建的,属于局部变量
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
}
// 这个也是线程不安全的,因为有返回值,有可能被其它的程序所调用
public static StringBuilder method04() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("a");
stringBuilder.append("b");
return stringBuilder;
}
// stringBuilder 是线程不安全的,操作的是共享数据
public static void method02(StringBuilder stringBuilder) {
stringBuilder.append("a");
stringBuilder.append("b");
}
/**
* 同时并发的执行,会出现线程不安全的问题
*/
public static void method03() {
StringBuilder stringBuilder = new StringBuilder();
new Thread(() -> {
stringBuilder.append("a");
stringBuilder.append("b");
}, "t1").start();
method02(stringBuilder);
}
// StringBuilder是线程安全的,但是String也可能线程不安全的
public static String method05() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("a");
stringBuilder.append("b");
return stringBuilder.toString();
}
}
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
总结一句话就是:如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
# 本地方法栈
Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。本地方法就是由c/c++编写的,用来与底层的系统比如操作系统交互的。
# 堆
Java8及之后堆内存逻辑上分为三部分:年轻代、老年代、元空间
其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)
为什么要把Java堆分代?不分代就不能正常工作了吗?新建对象具有朝生夕死的特点,而对于一些经过几次GC之后依然存活下来的对象,那么在接下来几次GC中也有很大可能不会被清理,比如数据库连接池的连接几乎不会被回收,对于这些对象我们不用每次都去扫描,而是把他们放在老年区,等到内存中实在放不下再去清理。
默认大小比例
Eden : S0 : S1 = 8 : 1 : 1
新生代 : 老年代 = 1 : 2
默认-XX:NewRatio=2,设置老年代与新生代的比例,表示新生代占1,老年代占2,新生代占整个堆的1/3
官方默认-XX:SurvivorRatio=8,设置Eden与S区的比例,表示Eden占8,但实际是6:1:1,要想是8:1:1,需要显式配置-XX:SurvivorRatio=8
2
3
4
5
# 堆参数设置与OOM
-Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
-X:是jvm运行参数
ms:memory start
-Xmx:用来设置堆空间(年轻代+老年代)的最大内存大小
// 在IDEA中Run/Debug Configuration中VM option中设置JVM的启动参数
-Xms10m -Xmx10m
// 可视化jvm内存工具
jvisualvm
2
3
4
5
6
7
8
9
10
11
class Main {
public static void main(String[] args) throws InterruptedException {
// 返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");
List<int[]> list = new ArrayList<>();
while (true){
Thread.sleep(20);
list.add(new int[10000]);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
控制台输出,可以看到OOM内存溢出
-Xms:96M
-Xmx:96M
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Main.main(Main.java:17)
2
3
4
# 对象分配的一般过程
我们一开始创建的对象,一般都是存放在新生代的Eden区,当Eden区满了后,会触发YoungGC。
将被GC Roots直接或间接引用的对象保留下来,存放到S0区,同时给每个幸存下来的对象设置一个年龄计数器,如果它在一次GC中幸存下来了,那么它的年龄就加1。
同时Eden去继续存放对象,当Eden区再次满了,又会触发YoungGC操作,此时GC将会把Eden和Survivor From中的对象进行一次收集,将存活的对象放到Survivor To区,同时年龄加1。
我们不断进行对象创建和垃圾回收,当Survivor中的对象年龄达到一个阈值的时候,将会触发一次Promotion晋升的操作,也就是将年轻代的对象晋升到老年代中。
Survivor From和Survivor To区是轮换的,为空的是To区,不为空的是From区,经过一次GC后From区变空成为To区,To区边为From区
特殊情况
- 新对象申请时,如果Eden放得下,就放到Eden区,放不下就触发YoungGC,将Eden区和From区有GC Roots引用的对象复制到To区,如果清理之后还能够放下就放到Eden区,如果放不下就放Old区,如果放得下就放Old区,如果放不下就触发Full GC,如果还放不下就报OOM异常
- 在YGC中,将Eden区和From区有GCRoot引用的对象复制到To区,但如果To区放不下了或者对象的年龄超过阈值,就将对象放到Old区
代码演示
我们在main方法中不断创建对象,然后在jvisualvm中查看堆区的内存情况
新对象一直放在Eden区,放满之后触发YGC,由于对象都在使用着,所有对象不会被回收,因此,一部分对象放到To区,一部分直接到Old区,随着对象的不断创建,最后Old区和Eden区满了就触发OOM异常。
可以看到每到Eden区存储的峰值就会触发YGC,而后将幸存对象放在To区和Old区。
# Minor GC
当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(每次Minor GC会清理年轻代的内存。),触发MinorGC时会STW。
STW:stop the world,停止用户线程,启动GC线程清理垃圾
# Major GC
老年代的GC,出现了MajorGc,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程),也会STW
# Full GC
触发FullGC执行的情况有如下五种:
- 调用System.gc()时,系统建议执行FullGC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space(From Space)区向survivor spacel(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些
可以在JVM启动参数中添加下列代码,打印GC日志信息。
-XX:+PrintGCDetails
# Minor GC、Major GC、Full GC、MixedGC对比
Minor GC:新生代的GC
Major GC:老年代的GC
Full GC:整堆收集,收集整个Java堆和方法区的垃圾收集
混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为
# TLAB
堆空间并不都是线程共享的,TLAB(Thread Local Allocation Buffer)就是JVM为每个线程单独分配的一个缓冲区。Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内,但TLAB一般比较小。
为什么要有TLAB?因为堆空间是线程共享的,给对象分配内存时需要加锁,避免多个线程将对象分配在同一个地址,但这样会影响分配的速度,给每个线程一份私有空间能够不用加锁从而能够快速分配对象。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
TLAB分配过程
首先我们new Object()
创建一个对象,JVM首先会判断这个类有没有被加载入内存,如果没有,就经过加载、链接、初始化三个阶段从磁盘中加载.class文件,加载之后给对象实例分配内存,看当前线程的TLAB能不能够放得下,如果放得下就放,放不下就在Eden区分配。
# 方法区
方法区
主要存放从class
文件中加载进来的类,JDK 1.8后这块区域改名为Metaspace
,即元数据空间,放的还是我们自己写的各种类相关的信息。
我们说Java中万物皆对象,在堆中的对象实例能够调用方法,是因为其对象头中包含了对象类型数据的指针,指向其在方法区中的类信息,通过它能够访问到方法区中具体的方法,方法中有字节码指令。
# 栈、堆、方法区的交互关系
- Person,整个类的结构存放在方法区
- person,存放在虚拟机栈中的一个栈帧的局部变量表中
- new Person(),存放在堆中
# 方法区内部结构
JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区,此时hotspot虚拟机对方法区的实现为永久代 JDK1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是hotspot中的永久代 。 JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace)
包含类信息和运行时常量池。
类信息
类型信息、域信息、方法信息等
运行时常量池
常量池表(Constant Pool Table)是class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
# 方法区使用举例
如下代码
public class MethodAreaDemo {
public static void main(String args[]) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a+b);
}
}
2
3
4
5
6
7
8
9
字节码执行过程展示