文章目录
JVM-栈和方法
仅做学习内容的简单记录
1.程序计数器
程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。线程私有。
2.虚拟机栈
虚拟机栈也是线程私有,而且生命周期与线程相同,每个Java⽅法在执行的时候都会创建一个栈帧(Stack Frame)。
栈帧
栈帧(Stack Frame)是用于支持虚拟机进入方法执行的数据结构
栈帧存储了方法的局部变量表、操作数栈、动态连接和⽅法返回地址等信息。每一个方法从调用到执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
当前栈帧
一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关联的方法称为当前方法,定义这个方法的类叫做当前类。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。
**调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。**方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。
局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。
例如:两数相加,需要将两个数字取到操作数栈里面,再进行计算。
一个线程的执行过程中,需要进行两个栈的入栈出栈操作,一个是JVM栈(栈帧的入栈和出栈),一个是操作数栈(参与计算的值进行入栈和出栈)
动态链接
在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
这个就是之前写的测试堆内存担保机制的代码,#2 这个就是testHandlePromotion()方法的符号引用
直接引用就是对应方法的内存地址
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态链接。
方法返回
当一个方法开始执行时,可能有两种方式退出该方法:
-
正常完成出口
-
异常完成出口
3.本地方法栈
本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法栈为虚拟机使用到的Native方法(比如C++方法)服务。
java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
事实上,我们所写的java代码已经用到了本地方法,在sun的java的并发(多线程)的机制实现中,许多与操作系统的接触点都用到了本地方法,这使得java程序能够超越java运行时的界限。有了本地方法,java程序可以做任何应用层次的任务。
4.方法执行
运行过程中会被即时编译器编译的“热点代码”有两类:
- 被多次调用的方法。
- 被多次执行的循环体。
jvm JIT运行方式
JVM有两种运行模式:Server模式与Client模式。可以通过-server或-client设置jvm的运行参数。
-
Server VM的初始堆空间会大一些,默认使用的是并行垃圾回收器,启动慢运行快。
-
Client VM相对来讲会保守一些,初始堆空间会小一些,使用串行的垃圾回收器,它的目标是为了让JVM的启动速度更快,但运行速度会比Server模式慢些。
默认情况:
- 32位操作系统
如果是Windows系统,不论硬件配置如何,都默认使用Client类型的JVM。
如果是其他操作系统上,机器配置有2GB以上的内存同时有2个以上CPU的话默认使用server模式,否则使用client模式。 - 64位操作系统
只有server类型,不支持client类型。
JIT使用
为什么要使用解释器与编译器并存的架构
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。
解释器与编译器特点
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。
JIT优化
1)公共子表达式消除
如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。
例如int d = (c*b)*12+a+(a+b*c);
其中c*b
和 b*c
都是一样的,在编译时就会被抽出变成int d = E*12+a+(a+E);
接下来根据一些基础的加减乘除的分配律结合律等进行优化最后为int d = E*13+a*2
2)方法内联
将方法调用直接使用方法体中的代码进行替换,这就是方法内联,减少了方法调用过程中压栈与入栈的开销。
private int add4(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}
add4方法优化后:
private int add4(int x1, int x2, int x3, int x4) {
return x1 + x2 + x3 + x4;
}
3)方法逃逸分析
逃逸分析的基本行为就是分析对象动态作用域: 当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
逃逸分析包括:
- 全局变量赋值逃逸
- 方法返回值逃逸
- 实例引用发生逃逸
- 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
public class EscapeAnalysis {
//全局变量
public static Object object;
public void globalVariableEscape(){//全局变量赋值逃逸
object = new Object();
}
public Object methodEscape(){ //方法返回值逃逸
return new Object();
}
public void instancePassEscape(){ //实例引用发生逃逸
EscapeAnalysis escapeAnalysis = null;
this.speak(escapeAnalysis);
//下面就可以获取escapeAnalysis的引用
}
public void speak(EscapeAnalysis escapeAnalysis){
escapeAnalysis = new Object();
System.out.println("Escape Hello");
}
}
没有触犯方法逃逸的才会进行后面的4、5、6
4)对象的栈上内存分配
JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
public class EscapeAnalysisTest {
public static void main(String[] args) {
long a1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
//查看执行时间
long a2 = System.currentTimeMillis();
System.out.println("cost " + (a2 - a1) + " ms");
//为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();
}
static class User {
}
}
我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化。
关闭方法逃逸分析参数-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
jmap -histo 2809
num #instances #bytes class name
----------------------------------------------
1: 524 87282184 [I
2: 1000000 16000000 StackAllocTest$User
3: 6806 2093136 [B
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
这里看到共创建了100万个对象,接下来再开启方法逃逸
JVM参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
jmap -histo 2859
num #instances #bytes class name
---------------------------------------------
1: 524 101944280 [I
2: 6806 2093136 [B
3: 83619 1337904 StackAllocTest$User
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
开启方法逃逸后堆上值创建了8万多对象
是不是所有的对象和数组都会在堆内存分配空间?
不一定,随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。但是这也并不是绝对的。就像我们前面看到的一样,在开启逃逸分析之后,也并不是所有User对象都没有在堆上分配。
5)标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据 。
//有一个类A
public class A{
public int a=1;
public int b=2
}
//方法getAB使用类A里面的a,b
private void getAB(){
A x = new A();
x.a;
x.b;
}
//JVM在编译的时候会直接编译成
private void getAB(){
a = 1;
b = 2;
}
6)同步锁消除
同样基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的完全没有必要加锁。 在JIT编译时期就可以将同步锁去掉,以减少加锁与解锁造成的资源开销。
public class TestLockEliminate {
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public static void main(String[] args) {
long tsStart = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
getString("TestLockEliminate ", "Suffix");
}
System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart)
+ " ms");
}
}
stringBuffer的源码中append方法是加了synchronized锁的,但是上面的写法不存在方法逃逸,没有线程安全问题,所以开启方法逃逸分析后会消除锁,结果会有变化,但这种变化一般不明显。
5.方法调用
1、所有私有方法、静态方法、构造器及初始化方法都是采用静态绑定机制。在编译器阶段就已经指明了调用方法在常量池中的符号引用,JVM运行的时候只需要进行一次常量池解析即可。
2、类对象方法的调用必须在运行过程中采用动态绑定机制(先找内联缓存[JIT优化项]、通过方法表去查找)。
仅做学习记录与交流,如有错漏敬请指出