内部结构
图示
- 线程独占:程序计数器、虚拟机栈、本地栈
- 线程共享:堆区、元数据区
-
内存
内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。
- JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证JVM的高效稳定运行。
不同的JVM对于内存的划分方式和管理机制存在着部分差异(比如j9就没有方法区)
线程
线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。
在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
- 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。
线程分为守护线程和普通线程,如果虚拟机中只剩下守护线程,那么虚拟机就可以关闭了。
工具
-
后台系统线程
虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
- GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
- 编译线程:这种线程在运行时会将字节码编译成到本地代码。
信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。
程序计数器
概念
也称PC寄存器,Program Counter Register,很小的空间,也是运行速度最快的存储区域,存储指令相关的现场信息,不存在OutOfMemoryError。
- 存储指向下一条指令的地址(相当于行号),也即将要执行的指令代码。由执行引擎读取下一条指令。
- 每个线程都有自己的程序计数器,生命周期与线程一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的VM指令地址;或者,如果是在执行native方法,则是未指定值(undefned) 。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
反编译查看
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
# 这里的序号就是指定地址,也称偏移地址,后面是对应的操作指令
# PC寄存器中存放的就是这个序号
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 10: 0
line 11: 2
line 12: 4
line 13: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
2 7 1 i I
4 5 2 j I
8 1 3 k I
}
SourceFile: "Demo1.java"
虚拟机栈
概述
也称Java栈,每个线程创建时都会创建一个虚拟机栈,内部保存栈帧(对应方法的调用),生命周期与线程一致
- 栈是运行时单位(管理程序运行问题,如何执行、如何处理数据、参与方法的调用和返回,存储的只是数据的引用和局部变量),而堆是解决数据存储问题(对象和数据怎么放、放在哪)
执行速度非常快,栈帧随着方法的结束而出栈,因此不存在垃圾回收问题,但是存在OOM
常见问题
容量(栈帧的数量)不够:StackOverflowError
可申请的内存(虚拟机的内存)不够:OutOfMemoryError
参数设置
-Xss,如-Xss256k
public class Demo2 {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
2468
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
Exception in thread "main" java.lang.StackOverflowError
栈帧(Stack Frame)
虚拟机栈的基本单位,一个方法对应一个栈帧。
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对应的方法就是当前方法
- 执行引擎的所有字节码指令只对当前栈帧进行操作
- 如果该方法中调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,称为新的栈帧
- 不同线程的栈帧是不能相互引用的
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常(这里值没有处理的异常)。不管使用哪种方式,都会导致栈帧被弹出
局部变量表
也称本地变量表,定义为一个数字数组,主要用于存储方法参数和定义在方法内的局部变量,包含基本数据类型、对象引用(reference)、方法的形参、returnAddress类型
- 由于线程私有(方法内部定义的变量),因此局部变量不存在多线程安全问题
- 局部变量表的容量大小在编译器就确定了,保存在方法的Code属性的maximum local variables数据项中,方法运行期间不会改变局部变量表的大小
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
- 局部变量表的最基本存储单位是Slot(变量槽),编译时期确定,32以内的类型(包括引用类型)占一个槽,64位的类型占两个槽
- JVM会为局部变量表中的每一个Slot分配一个访问索引,通过这个索引即可访问到局部变量表中指定的局部变量值
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
- 访问占用两个槽位的变量,只需要前一个索引即可
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列,这就解释了为什么静态方法不能引用this,因为this变量不存在于当前方法的局部变量表中
- 如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量很有可能会服用过期局部变量的槽位,达到节省资源的目的
被局部变量表中的变量直接或间接引用的对象都不会被垃圾回收器回收
操作数栈
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间,默认采用数组实现。
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
- 操作数在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据
- 如果方法带有返回值,则返回值也要压入当前栈帧的操作数栈中,并更新PC寄存器中下一跳需要执行的字节码指令
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈,比如执行复制、交换、求和等操作
- JVM的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。
- 栈中的任何一个元素都是可以任意的Java数据类型。32bit的类型占用一个栈单位深度64bit的类型占用两个栈单位深度
- 操作数栈不是采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问。
栈顶缓存技术
- 前面提过,基于栈式架构的虚拟机所使用的零地址指令(只有入栈出栈)更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,Hotspot JVM的设计者们提出了栈顶缓存(Tos,Top-of-stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器(指令更少,执行速度更快)中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含整个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如invokedynamic指令。
- 在Java源文件被编译到字节码文件中时所有的变量和方法引用都作为符号引用( symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是将这些符号引用转换为调用方法的直接引用。
- 字节码文件需要很多数据的支持,这些数据不能直接保存到字节码中,所以通过符号引用的方式引用相关的数据支持
方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
动态、静态链接
静态链接
- 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
- 对应的绑定机制称为早期绑定
- 动态链接
- 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
- 对应的绑定机制称为晚期绑定
- 方法的多态性就是晚期绑定的典型场景,父类参数,运行调用时使用的是子类重写的方法 ```java public class Demo7 { public void showAnimal(Animal animal) { animal.eat(); //晚期绑定 } public void showHunt(Huntable huntable) { huntable.hunt(); //晚期绑定 } }
abstract class Animal { public abstract void eat(); }
interface Huntable { void hunt(); }
class Cat extends Animal implements Huntable { @Override public void eat() { } @Override public void hunt() { } }
<a name="EEL7N"></a>
##### 非虚方法
- 编译期间就确定了方法的具体调用版本,运行时时不可变的,这样的方法称为非虚方法。
- 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
- 与多态性相抵触,因为多态一定涉及方法的重写
- 可通过字节码指定查看方法的类型invokestatic(静态)、invokespecial(构造器、私有、父类)、invokevirtual(虚方法)、invokeinterface(接口方法)、invokedynamic(动态)
```java
public class Father {
public Father() {
System.out.println("father的构造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father普通方法");
}
}
class Son extends Father {
public Son() {
super();
}
public Son(int age) {
this();
}
public static void showStatic(String str) {
System.out.println("son " + str);
}
public final void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
showStatic("yangtao");
super.showStatic("good");
showPrivate("hello");
super.showCommon();
super.showFinal();
showCommon();
info();
MethodInterface in = null;
in.methodA();
}
public void info() {
}
public void display(Father father) {
father.showCommon();
}
public static void main(String[] args) {
Son son = new Son();
son.show();
}
}
interface MethodInterface {
void methodA();
}
动态类型语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
String s = 123; //编译前就报错了,所以说Java是静态类型语言
方法返回地址
存放调用该方法的PC寄存器的值,让执行引擎回到上一个方法
方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而异常退出的,返回地址要通过异常表来确定,栈帧一般不保存这部分信息。
附加信息
-
面试题
- 举例栈溢出情况
- 无限递归
- 调整栈大小,能保证一出现溢出吗??
- 不能,无论多大的栈大小,遇到无限递归,无限的增加栈帧,总有溢出的时候
- 垃圾回收会涉及到虚拟机栈吗?
- 不会,虚拟机栈只有Error(StackOverFlowError)没有GC,GC主要存在于堆中
- 栈内存越大越好吗?
- 能避免过早出现StackOverFlowError,但是一个栈的内存过大会挤占其他线程的栈内存
方法中定义的局部变量是否存在线程安全问题?
- 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
- Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。堆内存的大小是可以调节的。
- 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
- 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区( ThreadLocal Allocation Buffer,TLAB)。
- 所有的对象实例以及数组都应当在运行时分配在堆上。
OOM
举例
无限的添加对象
public class Demo3 {
byte[] buffer = new byte[new Random().nextInt(1024 * 1024)];
public static void main(String[] args) throws InterruptedException {
ArrayList<Demo3> list = new ArrayList<>();
while (true) {
Thread.sleep(10);
list.add(new Demo3());
}
}
}
使用 jVisualVM查看内存情况
- 年老区一满,启动Full GC,还是没有足够空间,报OOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.yangtao.demo1.Demo3.<init>(Demo3.java:12)
at com.yangtao.demo1.Demo3.main(Demo3.java:18)
解决OOM
- 通过内存映像分析工具对dump转出来的堆转储快照进行分析,确认内存中的对象是否是必要的,也就是分清楚是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链(即泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置
- 如果不存在内存泄漏(即内存中的对象必须都还活着),那就检查虚拟机的堆参数(-Xmx与-Xms),与机器的物理内存对比看是否还可以调大,从代码中检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗
常用参数
- 官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
- -XX:+PrintFlagsInitial:查看所有参数的默认初始值
- -XX:PrintFlagFinal:查看所有参数的最终值
- 扩展常用命令:jinfo -flag SurvivorRatio 进程id
- -Xms:设置对控制初始内存
- -Xmx:最大堆空间内存
- -Xmn:设置新生区大小
- -XX:SurvivorRatio:设置新生区中Eden和S0 S1的空间的比例
- -XX:MaxTrnuringThreshold:设置新生代垃圾的最大年龄
- -XX:+PrintGCDetails:输出详细的GC处理日志
- -XX:+PrintGC、-verbose:gc:打印GC简要信息
- -XX:HandlePromotionFailure:是否设置空间分配担保(JDK7以后失效)
- 只要年老区的连续空间大于新生区的对象总大小或历次晋升的平均大小,就会进行Minor GC
- -XX:+DoEscapeAnalysis:开启栈上分配
-XX:+EliminateAllocations:开启标量替换
新生区与年老区
Java对象分两类:生命周期较短的瞬时对象,生命周期非常长的对象
- 几乎所有对象都是在Eden区被new出来,绝大部分的对象销毁都在新生区中进行
- 配置新生区和年老区在堆结构的占比
- -XX : NewRatio = 2,代表新生区占1,年老区占2(这也是JVM默认的比例)
- -XX : SurvivorRatio=8设置伊甸园区与幸存区的比例大小
- 但是在实际中并不是8:1:1,JVM有自适应策略
对象分配过程
- new的对象先放伊甸园区。此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 将伊甸园中的剩余对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。啥时候能去养老区呢?可以设置次数。默认是15次。
可以设置参数: -XX:MaxTenuringThreshold=进行设置
分代(分区)思想
为什么
分代可以优化GC性能。如果没有分代,那么所有的对象都在一块,GC的时候就要查找哪些对象是没有用的,这样会对所有的区域进行扫描。对象的生命周期不同,大部分都是临时对象,将新创建的对象放在新生区,GC的时候首先先对这部分的对象进行垃圾回收(Minor GC),这样就可以腾出很大的空间且不用对堆进行整体扫描(等到了一定的条件,如新生区满了,再对年老区进行扫描或进行Full GC整堆扫描)
内存分配策略
如果对象在Eden出生并且第一次Minor GC后仍然存活,并且能被Survivor区容纳的话,则将其移动到Survivor区,且增加对象的年龄。对象在Survivor区没经历一次Minor GC且存活下来,年龄就增加1。年龄增加到15的时候就将其移动到年老区。
- 新生的大对象如果Eden区放不下,直接放入年老区
- 动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于这个年龄的对象可以直接进入年老区,无需等到MaxThenuringThreshold中要求的年龄。
Minor GC后所有对象都没回收,则启动空间分配担保,当年老区的空间足够时,将新生的对象直接放入年老区
TLAB
Thread Local Allocation Buffer
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题。同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- TLAB是内存分配的首选,一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
堆的优化策略
逃逸分析
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
- 对于没有逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间被移除,并不会触发GC
- 注意:逃逸分析的是对象实例,而不是指向它的变量
- 启示:能使用局部变量就使用局部变量,不要在方法外定义
-
标量替换
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
- 相对的,那些还可以分解的数据叫做聚合量(Aggregate) ,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
class User {
public int id;
public String name;
}
User对象中含有int类型的变量和String类型的变量,在JIT阶段这个对象会被拆解成这两个标量,存放在栈空间中,而不是创建User对象放入堆空间
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
- 栈上分配的前提是开启了标量替换
不开启情况 -XX:-DoEscapeAnalysis
for (int i = 0; i < 10000000; i++) {
User user = new User();
}
开启后
同步省略(锁消除)
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
public void f() {
User user = new User;
synchronized(user) {
System.out.println(user);
}
}
JIT编译阶段会被优化成
public void f() {
User user = new User();
System.out.println(user);
}
方法区
概念
尽管方法区在逻辑上是属于堆的一部分,但是一些简单的实现可能不会选择去进行垃圾收集或进行压缩 ——《Java虚拟机规范》
对于HotSpotJVM而言,方法区还有一个别名,叫做Non-Heap(非堆),目的就是要和堆分开,所以,方法区可以看作是一块独立于Java堆的内存空间
- 和堆一样,是各个线程共享的区域
- 在JVM启动的时候创建,实际物理内存是可以不连续的,大小可固定可扩展
- 大小决定了系统可以保存多少类,如果定义太多类,会导致方法区溢出:OOM、Metaspace
- 加载大量第三方jar包、Tomcat部署工程过多、大量动态的生成反射类
- JDK7及以前,习惯把方法区称为永久代,JDK8开始,使用元空间取代了永久代
- 本质上方法区和永久代并不等同(除了HotSpot虚拟机)
元空间是对JVM规范中方法区的具体实现,但是元空间不在虚拟机设置的内存中,而是使用本地内存
设置方法区大小与OOM
JDK7及以前使用 -XX:PermSize=xxx,-XX:MaxPermSize=xxx- JDK8使用 -XX:MetaspaceSize=xxx、-XX:MaxMetaspaceSize=xxx
初始大小为21M(称为高水位线),超出就触发Full GC,然后重置高水位线,为了避免频繁出现Full GC,一般设置初始大小为一个比较高的值
内部结构
类信息
包含类class、接口interface、枚举enum、注解annotation
- 这个类的完整有效名称,即全类名
- 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)这个类型的修
- 修饰符(public, abstract, final的某个子集)
- 这个类型直接接口的一个有序列表
-
域信息
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public, private,protected, static,final, volatile, transient的某个子集)
方法信息
方法名称
- 方法的返回类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)、异常表(abstract和native方法除外)
每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final的类变量
静态变量和类关联在一起,随着类的加载而加载,它们称为类数据在逻辑上的一部分
-
常量池
方法区包含了运行时常量池
- 字节码文件中包含了常量池
- 字节码文件中加载到方法区的常量放在运行时常量池
- 常量池表包括各种字面量和对类型、域、方法的符号引用
- JVM为每一个加载的类都维护一个常量池
作用
- Java源文件中的类、接口,编译后产生一个字节码文件。而java中字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方法,可以存到常量池,整个字节码包含了指向常量池的引用,在动态链接的时候会用到运行时常量池
- 比如以下代码,编译成.class文件后虽然只有500多个字节,但是里面却有String、System、PrintStream即Object等结构(引用)
public class SimpleClass {
public void sayHello() {
System.out.println("hello");
}
}
有什么
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
总结
字节码中的常量池经过类加载器加载到方法区后,称为运行时常量池,是常量池运行时的一种表示形式
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获取的方法或字段引用,此时不再是常量池中的符号地址了,这里换为真实地址
- 重要特征:具有动态性
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutOfMemoryError
举例
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);
}
}
编译成 .class字节码文件后的执行流程
- 加载 .class字节码文件,把常量池加载进方法区成为运行时常量池
- 执行引擎读取程序计数器的内容,去运行时常量池寻找对应的指令
- 根据获得的指令去堆中寻找对象或在栈中操作局部变量、运算等操作
- 反复执行直至字节码指令执行完成,将方法返回结果返回或抛出异常,删除栈帧
方法区的演进
JDK1.6及之前
改动原因
- 为永久区设置空间大小是很难确定的
- 对永久区调优很困难
StringTable为什么放在堆中?
方法区的垃圾回收管理非常宽松,而且很难
- 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量、不再使用的类型。
- 先来说说方法区内常量池之中主要存放的两大类常量:字面量、符号引用。字面量比较接近Java语言层次的常重概念,如文本字符串、被声明为fianl的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- Hotspot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
- 回收废弃常量与回收Java堆中的对象非常类似。
- 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如oSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+Traceclass-Loading、- XX:+TraceClassUnLoading查看类加载和卸载信息
在大量使用反射、动态代理、cGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。这就解释了为什么常用的框架中的各种类都是使用自定义的类加载器。
* 栈、堆、方法区补充
三者联系
创建对象举例
对象的实例化
内存布局
对象访问定位
句柄访问
直接指针
直接内存
注:直接内存不是运行时数据区的一部分,在Java堆外,是直接向系统申请的内存区域
- 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
-
非直接缓冲区
读写文件需要与磁盘交互,需要由用户态切换到内核态
其他
- 也可能导致OOM异常
- 由于直接内存在Java堆外,它的大小不会受限于-Xmx,但是受限于系统的内存
- 缺点
- 分配回收成本较高
- 不受JVM内存回收管理
- 直接内存大小可以通过MaxDirectMemorySize设置
- 如果不指定,默认与堆的最大值-Xmx参数值一致
-
直接缓冲区
操作系统划分出的可以被java直接访问的区域,只有一份
执行引擎
概述
- JVM核心组成部分之一
- 从外观上看,所有的java虚拟机的执行引擎输入、输出都是一致的:输入的字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
执行引擎的作用
将字节码指令解释/编译为对应平台上的机器指令,即 将高级语言翻译为机器语言的译者。
工作过程
- 读取程序技术器中存放的指令,去运行时常量池寻找对应的字节码指令
- 解释字节码指令,将其翻译为本地机器指令交由CPU执行
- 每执行完一项指令后,PC寄存器就更新下一条需要被执行的指令地址
- 方法的执行过程中,执行引擎可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的实例对象信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
Java代码的编译和执行过程
编译过程
执行过程
字节码、机器码、指令
字节码
- 一种中间状态的二进制代码,比机器码更加抽象,需要解释器转译后才能称为机器码
-
汇编指令
由于指令的可读性还是太差,于是人们又发明了汇编语言。
- 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbo1)或标号(Labe1)代替指令或操作数的地址。
- 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
指令
由于机器码可读性较差而发明
- 一般用英文简写
-
指令集
不同的硬件平台支持不同的指令,每个平台所支持的指令,称为对应平台的指令集
- x86指令集
-
机器码
采用二进制编码方式表示的指令,就是机器语言。
- CPU直接读取运行,执行速度最快。
-
解释器
为了满足Java程序实现跨平台性,在设计的时候避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
- 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
- 在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的的模板解释器。
- 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下
- 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
- 在Hotspot VM中,解释器主要由Interpreter模板和Code模板构成
- Interpreter模块:实现了解释器的核心功能
- Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
- 在Hotspot VM中,解释器主要由Interpreter模板和Code模板构成
为了避免解释器的执行效率低问题,JVM支持一种叫做即时编译的技术,避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行的时候,只执行编译后的机器码即可。
JIT编译器
是什么
JIT编译器(Just In Time),为了提高执行效率的一种技术,将方法编译成机器指令后执行
一般与解释器互相协作,取长补短,尽力选择最合适的方式权衡编译本地代码的时间和直接解释执行代码的时间
热点代码
根据代码的执行频率,将频繁被调用的代码称为热点代码
JIT在运行期间会对热点代码做深度优化,将其直接编译为对应平台的本地机器指令
热点代码探测
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (on stackReplacement)编译。
- 目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter) 。
方法调用计数器
统计方法被调用的次数,默认阈值在Client模式下是1500次,在Server模式下是10000次,超过这个阈值就会触发JIT编译
- 阈值可以通过-XX:CompileThreashold来设定
热度衰减:一定的时间限度内,方法的调用次数不足以让它交给编译器编译,那么这个方法的调用计数器就会被衰减一半,称为方法的热度衰减,这段时间被称为方法统计的半衰周期。-XX:-UseCounterDecay可以关闭热度衰减,只要系统运行时间够长,绝大部分方法会被编译成本地代码,-XX:CounterHalfLifeTime可以设置半衰周期,单位为秒。
回边计数器
统计一个方法中环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,显然,建立回边计数器统计的目的就是为了触发OSR编译。
保留解释器
- 程序启动后,解释器可以立马执行,不用等待编译器编译全部的代码再执行
随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,换取更高的执行效率
设置程序执行方式
-Xint:完全采用解释器模式执行程序
- -Xcomp:完全采用即使编译器模式执行程序,如果编译出现问题,那么解释器会介入执行
-
JIT分类
-client:指定虚拟机运行在Client模式下,并使用c1编译器(对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度)
- -server:64位版本默认,指定虚拟机运行在Server模式下,并使用c2编译器(耗时较长的优化,以及激进优化,代码执行效率更高)
- 在不同的编译器上有不同的优化策略,c1编译器上主要有方法内联,去虚拟化、冗余消除。
- 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:在运行期间把一些不会执行的代码折叠掉
C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在c2上有如下几种优化:
jdk9引入了AoT编译器(静态提前编译器,Ahead of Time Compiler)
- Java 9 引入了实验性AOT编译工具jaotc。它借助了Graal 编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。
- 所谓AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。.java -> .class -> .so
- 最大好处
- Java虚拟机加载已经预编译成二进制库,可以直接执行。不必待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体
缺点:
线程私有,管理本地方法的调用
- 存在StackOverFlowError(超过栈最大容量值) 和 OutOfMemoryError(内存空间不足)
- 本地方法是使用c语言实现的。它的具体做法是Native Method stack中登记native方法,在Execution Engine执行时加载本地方法库
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。它甚至可以直接使用本地处理器中的寄存器
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一
本地方法接口
Native Method,就是Java调用非Java代码的接口,方法并非是Java实现,初衷是融合C/C++程序
native method
Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
- 与Java环境外交互
- 有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
- 与操作系统交互
- JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,JVM毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
- sun ‘s Java
- sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的 setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority()。这个本地方法是用C实现的,并被植入JVM内部,在windows 95的平台上,这个本地方法最终将调用win32 setPriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。