JVM组成部分
如下图:
结构简析:
- Java程序需要首先编译成字节码,再由JVM的类加载器模块加载到JVM当中
- JVM内存结构分为【方法区】、【Heap堆】、【虚拟机栈】、【程序计数器】、【本地方法栈】
- 方法区主要存在类的定义,像类字段、方法数据、方法和构造器代码(包括类构造器、实例构造器、接口构造器)
- 对象空间在堆区分配,而对象调用方法会创建栈帧,就要使用到虚拟机栈
- 而方法的执行(包括JVM指令)就需要程序计数器记住下一条要执行的命令
- Java程序无法调用操作系统api,需要借助本地方法(C或C++编写的,如Object类中的wait()、clone()等),而也本地方法的调用于普通方法类似,不过会存在本地方法栈当中
程序计数器
程序计数器有什么作用
它可以记住下一条指令的地址
程序计数器在什么时候发挥作用呢
- cpu的调度组件会给每个线程分配时间片,当时间片用完,线程会发生切换,而切换的线程需要知道之前指令执行的位置,这个时候就需要程序计数器器了
- 另外在指令的执行过程中,程序计数器也会实时更新,如下图经反编译生成的方法指令
- 指令前面的编号,我们其实就可以看作指令的内存地址,虽然指令之间间隔不同,那是因为一条指令由操作码和操作数组成,操作码由一个字节存储,操作数就确定了,所以地址间隔会有不同,每执行到当前指令,程序计数器就会记住下一条要执行的指令,而每条JVM指令通过解释器,最终由cpu执行
程序计数器器的特点
- 线程私有
- JVM结构中唯一一个不会出现内存溢出的错误
程序计数器的物理实现
通过寄存器实现
虚拟机栈(也称为线程栈)
它在JVM中的位置如下图:
虚拟机栈的概念
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存
- 每个线程只有有一个活动栈帧,对应着当前正在执行的那个方法
栈帧的结构
结构图如下:
栈帧的具体大小在编译期就能确定,栈帧的结构主要组成部分如下:
- 局部变量表
- 操作数栈
- 类引用,指向方法区中类的定义
- 方法引用(或者说方法返回地址),同样时方法区中类中的方法定义
- 方法返回值
虚拟机栈的思考
- 垃圾回收是否涉及内存
- 栈内存分配越大越好吗?
方法内的局部变量是否线程安全?
- 不涉及,垃圾回收的区域主要针对heap堆(也称作GC 堆),即由垃圾回收器管理的堆,而对于虚拟机栈,栈帧随着方法调用创建,随着方法结束弹出,因此它的内存会自动销毁,并不需要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
27
28
29
30
31
32
33
34
35
public class ThreadTest{
public static void 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和m3
- 我们发现,m2,m3要么对象是形参,要么会作为返回值返回,而判断对象是否存在线程安全问题,就在于一个对象会不会存在多个线程访问,显然无论是返回值还是形参,都有可能被其他线程所访问,所以m2和m3是存在线程安全问题的,这个时候就应该使用StringBuffer而不是StringBuilder
栈内存溢出
栈内存溢出的常见场景
- 栈帧过多(如无限递归调用、循环依赖)
- 栈帧多大,即一个栈帧非常大,直接撑爆了虚拟机栈
栈内存溢出演示代码之无限递归1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class StackOverflowErrorTest{
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();
}
}
在服务器上运行这个程序,会报下面的错误:
1
2
3
4
5
6
7
8
9
10
11
java.lang.StackOverflowError
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
.....
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
21705
这个就是典型的栈溢出错误了java.lang.StackOverflowError
,在IDEA中可以调整堆内存的大小,如下图:
修改之后可以看到count的数值明显变小了,虚拟机栈的默认大小是1M
1
2
3
4
5
6
7
java.lang.StackOverflowError
at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
7755
栈内存溢出演示代码之无限递归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
/**
* 第三方json转换库调用
*/
public class StackOverflowErrorTest2{
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));
}
}
@Data
class Dept{
private String name;
private List<Emp> emps;
}
@Data
class Emp{
private String name;
//解决方法
// @JsonIgnore
private Dept dept;
}
这段代码存在无限递归:属性之间存在循环依赖,Dept依赖Emp,Emp依赖Dept,结果就导致循环依赖报错如下:
1
Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
线程运行诊断
案例1:CPU占用过多
某个程序CPU占用高达90%以上,影响其他程序的运行
code如下:
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
public class PayloadOfCPUTest {
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(1000000L);
} catch (InterruptedException e){
e.printStackTrace();
}
}, "thread2").start();
new Thread(null, () -> {
System.out.println("3....");
try{
Thread.sleep(1000000L);
} catch (InterruptedException e){
e.printStackTrace();
}
}, "thread3").start();
}
}
以下面的命令开始启动程序,并进行运行诊断
1.首先在服务器输入命令:nohup java PayloadOfCPUTest &
,让程序在后台运行
2.输入top
实时检测Java代码,如下图所示:
我们发现top能监测到某个进程的cpu占用情况,并且可以查看到进程ID,但是无法定位到具体的线程,并且无法定位到代码中具体的位置
3.输入ctrl c
结束top命令,然后继续输入命令:ps H -eo pid,tid,%cpu
,其中H代表输出所有进程,-eo 表示具体想要显示的信息,这里就会显示进程ID,线程ID,和cpu占用,显示结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
PID TID %CPU
1 1 0.0
8 8 0.0
......
14799 14809 0.0
14799 14810 0.0
14799 14811 99.9
14799 14812 0.0
14799 14813 0.0
......
我们可以发现,除了我们要定位到的进程和线程信息,还有很多其他的信息,这时候可以用命令ps H -eo pid,tid,%cpu | grep 14799
,过滤出我们想要的进程的所有线程信息,结果如下:
这样我们就定为到了具体的线程ID了
4.为了查看具体的线程状态我们使用jdk的一个命令:jstack 进程ID
,这个命令能打印输出该进程的所有线程状态信息的快照,输入后得到如下结果:
在这其中我们发现了自己创建的thread1线程,而其他的特殊名字的线程都是JVM自己的线程,从打印信息中看我们的程序处于Runnable状态,并且下面还定位到了代码中可能出现问题的位置,第5行,我们用vim命令打开PayloadOfCPUTest.java文件,发现确实在第5行出现了while死循环,这也就是问题的根源:
5.根据ps命令我们定位到了线程ID,而在jstack打印的线程信息中,编号是16进制的,将14811转换成16进制39DB,我们发现就是thread1
6.在去修改代码之前,别忘了结束进程,kill 14799
,kill 进程ID,结束进程
到这我们的线程运行诊断就结束了,我们已经定位到了具体的代码行数,接下来就可以自行修改代码,解决问题了
注意在IDEA中有个自带的功能可以是先jstack的效果
案例2:程序运行很久没有结果(死锁)
code如下:
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
package com.flameking.stack;
class A { }
class B { }
public class DeadlockTest {
static A a = new A();
static B b = new B();
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
//1.线程先获得a的monitor锁,接下来便进行休眠
synchronized (a) {
try {
Thread.sleep(2000);
} catch (InterruptedException e){
e.printStackTrace();
}
//4.此时线程先要获取b对象的monitor锁,但是也已经被占用,所以进入阻塞状态
synchronized (b){
System.out.println("我获得了a和b");
}
}
}).start();
//2.main线程休眠
Thread.sleep(1000);
//3.在其他线程休眠期间,该线程会获取到对象b的monitor锁,进而想要获取a的monitor锁
//然而目前a被其他线程,占用,该线程进入阻塞状态
new Thread(() -> {
synchronized (b){
synchronized (a){
System.out.println("我获得了a和b");
}
}
}).start();
}
}
这个程序最终两个线程会因为对方互相占用了锁并且也不释放,导致死锁,下面演示排查过程,如下图:
- 执行命令:
nohup java DeadlockTest &
- 执行命令:
jstack 进程ID
我们可以看到jstack打印信息的最后面的部分,可以看到下面的信息:
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
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007fab28003778 (object 0x00000000ecc5d220, a A),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007fab280062c8 (object 0x00000000ecc5de68, a B),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadlockTest.lambda$main$1(DeadlockTest.java:27)
- waiting to lock <0x00000000ecc5d220> (a A)
- locked <0x00000000ecc5de68> (a B)
at DeadlockTest$$Lambda$2/303563356.run(Unknown Source)
at java.lang.Thread.run(Thread.java:750)
"Thread-0":
at DeadlockTest.lambda$main$0(DeadlockTest.java:17)
- waiting to lock <0x00000000ecc5de68> (a B)
- locked <0x00000000ecc5d220> (a A)
at DeadlockTest$$Lambda$1/471910020.run(Unknown Source)
at java.lang.Thread.run(Thread.java:750)
Found 1 deadlock.
从第一行我们就能明显看出,程序出现了死锁问题,从370行后面能够看到死锁产生的具体原因,到这我们的死锁诊断就完成了
总结
在线程诊断中,我们用到了下面这些命令:
nohup java 程序名 &
,后台运行Java程序top
,实时监控进程cpu占用情况ps H -eo pid,tid,%cpu | grep 进程ID
,过滤出指定进程的所有线程占用cpu情况jstack 进程ID
,能打印出线程更加详细的状态信息快照
本地方法栈
也是线程私有的,它给本地方法的运行提供内存,本地方法(native method):不是由Java语言编写的(Java不能直接与操作系统api交互,这时候就需要使用本地方法,它们大多是使用C、C++编写的)
GC堆
通过new关键字创建的对象都会使用GC堆的内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制,具体也是针对GC堆
GC堆在堆内存中的位置如下:
从图中来简单看看堆内存的组成,后面会详细介绍:
- heap堆(GC堆)
- 年轻代
- 新生代
- 存活区:S0、S1
- 老年代
- 年轻代
- 非堆(Non-Heap)
- 方法区
堆内存溢出
试想一个问题:
为什么堆内存有垃圾回收机制自动回收垃圾,还会出现堆内存溢出现象呢?
因为堆内存会将那些空闲的对象,比如在代码中将对象引用指向null后,对象就会自动有垃圾回收器回收
但也有这样的情况,程序不断的生成对象,而且每个对象都有在使用,这样慢慢积累对象,最后就会出现堆内存溢出
堆内存溢出演示代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class HeapSpaceTest{
public static void main(String[] args) {
int i=0;
try{
List<String> list = new ArrayList<>();
String a = "hello";
while(true){
list.add(a);
a = a + a;
i++;
}
} catch(Throwable e){
e.printStackTrace();
System.out.println(i);
}
}
}
代码大致逻辑:不断拼接字符串,然后将每次拼接得到的字符串存在list当中,因为list一致不断在使用,同时字符串也存在list中,因此都有在使用,所以不会有垃圾回收
运行一段时间后,报错:
1
2
3
4
5
6
7
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at com.flameking.HeapSpaceTest.main(HeapSpaceTest.java:14)
26
修改堆内存的大小
通过-Xmx指令修改堆内存大小:
修改后能明显发现i的值变小,测试后i为17
注意:如果我们要排查堆内存溢出问题,应该把堆内存设置的小一些,这样能够尽早的排除出问题
堆内存诊断工具
- jps工具,查看当前系统中有哪些Java进程
- jmap工具,拿到进程ID后,查看堆内存使用情况,只能查询某个时刻堆内存情况
- jconsole功能,图形界面,多功能检测进程,线程,cpu等,并能连续检测
诊断演示代码
1
2
3
4
5
6
7
8
9
10
11
12
13
public class HeapToolTest {
public static void main(String[] args) throws InterruptedException {
System.out.println("1.....");
Thread.sleep(30000);
byte[] array = new byte[1024*1024*10]; //10Mb
System.out.println("2.....");
Thread.sleep(20000);
array = null;
System.gc();
System.out.println("3.....");
Thread.sleep(1000000L);
}
}
程序大致逻辑:程序运行时会有三个不同点状态,第一个是起始运行状态,第二个是创建了一个10Mb字节数组的状态,第三个是回收了字节数组的状态
使用诊断工具检测堆内存状态
注意这些工具请在Linux环境下运行,jmap在Windows环境下不太好用
1.用jsp命令查询所有的进程,输入:jps
2.执行命令:jmap -heap 进程ID
,这样我们能在其中看到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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Attaching to process ID 14120, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.341-b10
using thread-local object allocation.
Parallel GC with 2 thread(s)
Heap Configuration:
MinHeapFreeRatio = 0
MaxHeapFreeRatio = 100
MaxHeapSize = 968884224 (924.0MB)
NewSize = 19922944 (19.0MB)
MaxNewSize = 322961408 (308.0MB)
OldSize = 40894464 (39.0MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)
Heap Usage:
PS Young Generation
Eden Space:
capacity = 15728640 (15.0MB)
used = 629224 (0.6000747680664062MB)
free = 15099416 (14.399925231933594MB)
4.000498453776042% used
From Space:
capacity = 2097152 (2.0MB)
used = 0 (0.0MB)
free = 2097152 (2.0MB)
0.0% used
To Space:
capacity = 2097152 (2.0MB)
used = 0 (0.0MB)
free = 2097152 (2.0MB)
0.0% used
PS Old Generation
capacity = 40894464 (39.0MB)
used = 0 (0.0MB)
free = 40894464 (39.0MB)
0.0% used
710 interned Strings occupying 47392 bytes.
以上两个工具介绍完成,开始介绍jconsole工具的使用,在命令行输入:jconsole
命令,会弹出一个窗口:
点击要检测的程序,进行连接,连接后的界面如下: 其中的Threads功能界面就可以发挥jstack的作用
堆内存工具诊断案例:垃圾回收后内存占用仍然很高
code如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.ArrayList;
import java.util.List;
public class GCTest {
public static void main(String[] args) throws InterruptedException {
List<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++) {
students.add(new Student());
}
Thread.sleep(1000000000L);
}
}
class Student{
private byte[] big = new byte[1024*1024];
}
我们先使用jps和jconsole命令诊断,然后在jconsole中进行手动GC回收,如下:
虽然使用了上诉两个命令,但是仍然没能排查出问题,所以下面我们使用一个工具,叫做jvisualvm,输入命令:jvisualvm,得到如下界面:
点击进入到我们要排查的程序,这里是GCTest
,进去之后能看到下面的界面:
而我们实际要进入的界面是下面的这个:
前20最大的对象信息如下:
第一个ArrayList对象的内部信息如下:
总结
我们总共介绍了四款工具,分别是:
- jps,查看所有的Java进程
- jmap,查询Java进程的堆内存使用情况
- jconsole,实时查看堆内存情况,功能也更多
- jvisualvm,它基本包含了1,2,3的功能
方法区
方法区存在的位置就是非堆当中,但它其实是一个规范,具体要看各个jvm的实现
方法区的特性
- 运行时常量池,类定义信息:类字段,方法数据,方法和构造器代码(类构造器、实例构造器
- 方法区在虚拟机启动时创建,并且在逻辑上是堆的一部分(在不同的jvm实现上会有不同,虽然规范这样要求,但也不是每个jvm实现都遵守了的)
- 方法区会导致JVM内存溢出
下面是hotspot在1.6和1.8方法区的不同实现:
在1.6中,方法区的实现被称作永久代,里面包含常量池信息,类定义信息,类加载器信息,并且放在堆内存中
而在1.8中被称作元空间,这个时候StringTable从常量池抽取出来放在了Heap堆中,而元空间整个移进了本地内存中(也就是操作系统内存)
方法区内存溢出
演示代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
public class MethodspaceTest extends ClassLoader{
public static void main(String[] args) {
int j = 0;
try {
MethodspaceTest test = new MethodspaceTest();
for (int i = 0; i < 10000; i++, j++) {
//ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
//版本号(1.8),public(类的访问修饰符),类名,报名,父类,接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
//返回byte[]
byte[] code = cw.toByteArray();
//执行类的加载(大量的类加载到元空间就会造成内存溢出)
test.defineClass("Class" + i, code, 0, code.length);
}
}finally {
System.out.println(j);
}
}
}
之前我们在jmap中输出的信息看到,元空间的最大空间可以很大,因此这里运行并不会导致内存溢出,我们可以通过指令-XX:MaxMetaspaceSize=8m
,将元空间大小改位8m,再次执行,结果如下:
1
2
3
4
5
6
5411
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
at com.flameking.methodspace.MethodspaceTest.main(MethodspaceTest.java:19)
如果是1.6版本的话,这里报的错就是java.lang.OutOfMemoryError: PermGen space
实际应用中可能会出现元空间溢出的场景
Spring、MyBatis使用cglib采用的字节码动态生成技术生成代理类,它们其实就非常容易导致OOM错误的,我们上面的演示代码用的就是跟Spring、MyBatis同样的字节码动态生成技术
运行时常量池
首先看看常量池,我们使用javap -v .class文件,会输出类的信息(类基本信息,如类的访问修饰符,类使用的Java版本,类的名称,类的父类,类的接口信息等等),常量池信息,类方法的定义(包含虚拟机指令)
常量池和运行时常量池
- 常量池就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 常量池是.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号变为真实地址