前言
欢迎关注微信公众号“江湖喵的修炼秘籍”
撰文之前看到一段话,颇为喜欢,任性的写在这里:“入楼十七日,日日苦修,却修不到字词入心,只能眼睁睁看着它们溜走。我曾清醒过,也曾无来由的堕入黑甜梦乡,但它们总是不在,如果纸面上的它们是虚妄的,为何我能看见它们,如果它们是真实的,为何我不能记住它们。修行,到底是真实,还是虚妄,再上层楼,再上层楼,先前诸般愁,此时俱休。”–摘自《将夜》
我们所身处的,就是江湖。行走江湖,就离不开内功修炼,光靠三脚猫的功夫是不行的,需要内外兼修才有可能成为扫地僧一样的绝世高人。最近花了一些时间断断续续的读了周志明先生的《深入理解Java 虚拟机·JVM高级特性与最佳实践》第2版(该版本基于JDK1.7),结合官方的《JAVA虚拟机规范(Java SE 8版)》,总结一下自己的学习笔记和心得。
Java内存区域
越过虚拟机建起的高墙,窥探虚拟机内存管理的玄机。
运行时数据区
Java 虚拟机定义了几种程序运行时会使用到的数据区,从线程隔离性上分为两类:一类是于所有线程共享的数据区,包括方法区和堆,这两个区域会随着虚拟机的启动而创建,随着虚拟机的退出而销毁;另一类是线程隔离的,包括程序计数器(JAVA虚拟机规范中又叫PC寄存器),虚拟机栈和本地方法栈,这三个区域随着线程的启动而创建,随着线程的结束而销毁。
1.程序计数器
[1]Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的,一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程的指令,因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的计数器,各线程之间的计数器互不影响,独立存储[2]如果线程执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native 方法,这个计数器的值则为空。[3]此内存区域是Java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域。 --摘自《深入理解Java虚拟机》
思考一个问题,Java是如何实现多线程的?
面对这个问题,我们首先想到的答案是“继承Thread类,实现Runnable接口,实现Callable接口”,但原理是什么呢?
我们知道,一个处理器或一个内核同一时刻只会处理一个线程的指令,那么对于一个单核CPU要如何实现多线程呢?这就需要上述的[1]解释了,Java 虚拟机是通过快速切换线程并分配处理器执行时间实现的。比如A线程先向处理器发起一条指令,执行到一半时,B线程过来执行,且优先级高,此时处理器会将A挂起,执行B,当B执行完成后唤醒A继续执行。
由此我们引申出一个新的问题:
唤醒A 时如何确保A可以从上次中断的位置继续执行?
先看下面的代码[代码1]:
public class Test {
public void add() {
int a = 100;
int b = 200;
int c = a + b;
}
}
我们把它变编译成class文件,然后使用javap命令获取其字节码文件:
admindeMBP:auto-code-plugin nagsh$ javap -c Test.class
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void add();
Code:
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: iload_1
8: iload_2
9: iadd
10: istore_3
11: return
}
这个字节码文反映了add方法的执行过程,类似于bipush的JVM指令在这里先不做阐述,将在后边的内容中具体解释,现在我们仅仅关注Code下的数字0-11,这些数字就是所谓的偏移地址,也就是[2]中的虚拟机字节码指令的地址。程序计数器就是用来存放这些数字的,当线程A被唤醒后,只要通过程序计数器就可以获取到中断的位置,继续执行。由于仅仅只是存放的值会发生变化,而不会随着程序的运行需要更大的空间,所以不会发生内存溢出的情况,因此程序计数器有[3]所说的特点。
什么是native方法呢?
native方法就是非Java的方法,比如可能是C 实现的,在字节码文件中并不会体现,所以native方法的计数器值是空的。比如System.currentTimeMillis();方法就是一个native方法,声明如下:
public static native long currentTimeMillis();
我们改造前边的代码,加入该方法的调用[代码2]:
public class Test {
public void add() {
int a = 100;
int b = 200;
int c = a + b;
System.currentTimeMillis();
}
}
再查看对应的字节码文件:
admindeMBP:auto-code-plugin nagsh$ javap -c Test.class
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void add();
Code:
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: iload_1
8: iload_2
9: iadd
10: istore_3
11: invokestatic #2 // Method java/lang/System.currentTimeMillis:()J
14: pop2
15: return
}
可以看到整个字节码文件中仅仅是多了方法调用的过程,对于currentTimeMillis方法的实现并未体现,程序计数器当然在执行的时候也不会记录偏移地址。
而native方法的多线程是如何实现的?答案是原生语言是怎么实现就是怎么实现,如果方法实现是C,那C是如何实现线程切换的,java的native方法就是如何实现线程切换的。
2.Java虚拟机栈
Java虚拟机栈也是线程私有的,每个方法在执行是都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法接口等信息。每一个方法调用的过程就对应这一个栈帧在虚拟机中入栈出栈的过程。 --摘自《深入理解Java虚拟机》
局部变量表
用于存储基本数据类型及对象的引用,基于下面的代码3,我们通过javap命令查看其局部变量表:
import java.util.HashMap;
import java.util.Map;
public class Test {
public void add() {
byte a = 1;
short b = 1;
int c = 1;
long d = 1L;
float f = 1.0f;
double g = 1.0d;
boolean h = true;
char i = '1';
Map map = new HashMap();
}
}
局部变量表:
admindeMBP:auto-code-plugin nagsh$ javap -l Test.class
Compiled from "Test.java"
public class Test {
public Test();
LineNumberTable:
line 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTest;
public void add();
LineNumberTable:
line 6: 0
line 7: 2
line 8: 4
line 9: 6
line 10: 9
line 11: 12
line 12: 15
line 13: 18
line 14: 22
line 15: 31
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 this LTest;
2 30 1 a B
4 28 2 b S
6 26 3 c I
9 23 4 d J
12 20 6 f F
15 17 7 g D
18 14 9 h Z
22 10 10 i C
31 1 11 map Ljava/util/Map;
}
Signature表示变量的类型,Name表示变量的名称,Slot表示占有的卡槽位。add方法LocalVariableTable第一行表示的是方法自身的引用,第二行表示变量a类型是byte,占有的卡槽位是1。需要注意的是long和double占用两个卡槽位,分别是4-5和7-8。关于其他列的含义,我们通过查看前边代码1的局部变量表来解释:
admindeMBP:auto-code-plugin nagsh$ javap -c -l Test.class
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTest;
public void add();
Code:
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: iload_1
8: iload_2
9: iadd
10: istore_3
11: return
//左侧的数字对应着代码的行号,右侧的数字对应的在字节码中的偏移位置
LineNumberTable:
line 3: 0
line 4: 3
line 5: 7
line 6: 11
LocalVariableTable:
//Start和Start+Length表示变量在字节码中的生命周期,如this 对象从偏移位置0开始直到方法结束,变量从偏移位置3开始直到3+9方法结束
Start Length Slot Name Signature
0 12 0 this LTest;
3 9 1 a I
7 5 2 b I
11 1 3 c I
}
综上我们可以看出局部变量表中存储的是变量的偏移地址起始位置,生命周期,卡槽位,变量名称,变量类型。
行号表中存储了代码行对应字节码文件偏移地址的映射关系。
操作数栈
我们仍然使用代码1的字节码文件和局部变量表来了解操作数栈,前面提到方法的执行就是栈帧入栈出栈的过程,下面我们重点关注add方法的字节码文件,每一步的解释我会写在后面
admindeMBP:auto-code-plugin nagsh$ javap -c -l Test.class
Compiled from "Test.java"
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
St