本博客为炼数成金 JVM教程的第二课的学习总结
博客大纲
- JVM启动流程
- JVM基本结构
- 内存模型
- 编译和解释运行的概念
JVM启动流程
JVM基本结构
PC寄存机
- 每个线程拥有一个PC寄存器
- 在线程创建时创建
- 执行下一条指令的地址
- 执行本地方法时,PC的值为undefined
方法区(用来保存类的原信息)
- 类型的常量池
- 字段,方法信息
- 方法字节码
- 但也不是绝对的,JDK6时,String等常量信息至于方法区,但在JDK7时,已经移动到了堆
通常和永久区(Perm:保存相对稳定的数据)关联在一起
Java堆
- 和程序开发密切相关
- 应用系统对象都保存在Java堆中
- 所有线程共享java堆
- 对分代GC来说,堆也是分代的
GC的主要工作区间
Java栈
- 线程私有
- 栈由一系列帧组成(因此Java栈也叫作帧栈)
- 帧保存一个方法的局部变量,操作数栈,常量池指针
- 每一个方法调用创建一个帧,并压栈
局部变量表:包含参数和局部变量
局部变量表要很多的槽位,每一个槽位可以容纳32位的数据类型,所以int占一个槽位,long就要占两个槽位,对象存的是引用
public class StackDemo{
public static int runStatic(int i, long l, float f, Object o, byte b){
return 0;
}
public int runInstance(char c, short s, boolean b){
return 0;
}
}
静态方法的局部变量表
实例方法的局部变量表的分配方法跟静态方法的区别在于,实例方法的第一个槽位放的是this,即当前对象的引用,其余是一致的
java栈 - 函数调用组成帧栈、
public static int runStatic(int i, long l, float f, Object o, byte b){
return runStatic(i, l, f, o, b)
}
每一次方法调用,就会有一个帧,往栈里压,一直压到帧栈满了溢出,如果方法结束了,那么就从这个栈里移掉
Java栈 - 操作数栈
Java没有寄存器,所有参数传递使用操作数栈
public static int add(int a, int b){
int c = 0;
c = a + b;
return c;
}
下面是上述代码的反编译代码
有三个变量,abc,所有局部变量有三个,分别为123
我们模拟100+98的加法运算,看看数据在局部变量和操作数栈中的变化
Java栈- 栈上分配
栈上分配和堆上分配的区别:
堆上分配:每次需要清理空间
栈上分配:函数调用完成自动清理
public class OnStackTest{
public static void alloc(){
byte[] b = new byte[2];
b[0] = 1;
}
public static void main(String[] args){
long b = System.currentTimeMillis();
for(int i = 0; i < 10000000000; i++){
alloc();
}
long e = System.currentTimeMillis();
System.out.println(e-b);
}
}
运行上述代码:
可见,第二次输出的时候有大量的GC日志,可见第二次运行时,这些byte数组是在堆上做分配,且因为内存不足,会进行GC,可见第二次分配在堆上,第一次分配在栈上。
栈上分配的好处:
小对象(一般几十个bytes),在没有逃逸(只被自己用,不被其余线程使用)的情况下,可以直接分配在栈上
直接分配在栈上,可以自动回收,减轻GC压力
大对象或者逃逸对象无法栈上分配
堆、栈、方法区的交互
public class AppMain
// 运行时,JVM把appmain的信息都放入方法区
{
public static void main(String[] args)
// main 方法本身放入方法区
{
Sample test1 = new Sample("测试1");
// test1 是引用,所以放在栈区里,Sample是自定义对象,应该放到堆里面
Sample test2 = new Sample("测试2");
test1.printName();
test2.printName();
}
}
public class Sample
// 运行时,JVM把Sample的信息都放入方法区
{
private name;
// new Sample 实例后,name引用放入栈区里,name对象放入栈里
public Sample(String name){
this.name = name;
}
// print方法本身放入方法区里
public void printName(){
System.out.println(name);
}
}
可见,对象本身是在堆中的,栈中存放对象的引用
由栈指向堆,堆从方法区里获取方法等信息。
内存模型
- 每一个线程有一个工作内存和主存
- 每一个线程的工作内存存放主存中变量的值的拷贝
当数据从主内存复制到工作存储时,必须出现两个动作:
- 第一,由主内存执行的读(read)操作;
- 第二, 由工作内存执行的相应的load操作;
当数据从工作内存拷贝到主内存时,也出现两个操作:
- 第一个,由工作内存执行的存储(store)操作。
- 第二:由主内存执行的相应的写(write)操作每一个操作都是原子的,即执行期间不会被中断。
例如在read中不能被中断,但 read 和load 之间是可以被中断的对于普通变量,一个线程中更新的值,不能马上反映到其他变量中,如果需要在其他线程中立即可见,需要使用volatile关键字
线程总是在本地内存中存储变量,本地内存中只是共享变量的副本,在共享变量是存储在主内存中的,所以这之间存在时差。可用volatile避免这个问题
public class VolatileStopThread extends Thread{
private volatile boolean stop = false;
public void stopMe(){
stop = true;
}
public void run(){
int i = 0;
while(!stop){
i++;
}
System.out.println("Stop thread");
}
public static void main(String[] args) throws InterruptedException{
VolatileStopThread t = new VolatileStopThread();
t.start();
Thread.sleep(1000);
t.stopMe();
Thread.sleep(1000);
}
}
使用volatile的例子,一个线程一直在给i做加1操作,另一个线程将stop的值设置为true让线程停止加1,加了volatile之后,可以让其立即停止。
volatile不能代替锁,一般认为volatile比锁性能好(不绝对)
选择使用volatile的条件是:当前场景的语义是否满足volatile的应用
一些跟内存模型相关的概念性的东西
1.可见性
一个线程修改了变量,其他线程立刻可以知道
保证可见性的方法
- volatile
- synchronized (unlock 之前, 写变量值回主存)
- final (一旦初始化完成,其它线程就可见)
2.有序性
在本线程中,操作都是有序的
在线程外观察(多线程情况下),操作都是无序的(指令重排 或 主内存同步延时)
3.指令重排
线程内串行语义
写后读 a=1;b=a; 写一个变量之后,再读这个位置
写后写 a=1;a=2; 写一个变量之后,再写这个变量
读后写 a=b; b=1; 读一个变量之后,再写这个变量
以上语句不可重排,因为重排会导致定义出错,不符合开发要求
a=1;b=2;
这条语句可以重排,因为两个变量的赋值不会互相影响
与此同时,编译器不考虑多线程间的语义,因此指令重排会破坏线程间的有序性
class OrderExample{
int a = 0;
boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void reader(){
if(flag){
int i = a + 1;
......
}
}
}
线程A首先执行writer() 方法
线程B接着执行reader() 方法
线程B在int i = a + 1 是不一定能看到a 已经被赋值为1
因为在writer中,两句话顺序可能打乱
可能当线程A执行完flag = true之后,线程B执行了reader操作,但此时其实a 仍然等于 0
这种有序性被破坏,就会导致线程运行的结果不符合预期
指令重排 - 保证有序性的方法
class OrderExample{
int a = 0;
boolean flag = false;
public synchronized void writer(){
a = 1;
flag = true;
}
public synchronized void reader(){
if(flag){
int i = a + 1;
......
}
}
}
同步后,即使用了writer重排,因为互斥的缘故,reader 线程看writer 线程也是顺序执行的
可见线程A和线程B是顺序执行的,可以避免指令重排导致的问题
指令重排的基本原则
- 程序顺序原则:一个线程内保证语义的串行性
- volatile规则: volatile变量的写,先发生于读
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A先于B,B先于C,那么A必然先于C
- 线程的start方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断(interrupt()) 先于被中断线程的代码
- 对象的构造函数执行结束先于finalize()方法
JVM字节码运行的两种方法
- 解释运行
解释执行以解释方式运行字节码
解释执行的意思是:读一句执行一句 - 编译运行(JIT)
将字节码编译成机器码
直接执行机器码
运行时编译
编译后性能有数量级的提升