什么是Jvm?
-
翻译机
首先,你可以把Java虚拟机看作一个抽象的计算机,它有各种指令集和各种运行时数据区域。它就是一台翻译机器,将生成的字节码,翻译成各个操作系统能懂的指令。
-
跨语言、跨平台
JVM是JRE(Java Runtime Environment)的一个组成部分。
虽然叫Java虚拟机,但其实在它之上运行的语言可不仅仅是Java,还包括Kotlin、Groovy等。
Java虚拟机执行流程分为两大部分,分别是编译时环境和运行时环境,当一个Java文件经过Java编译器编译后会生成Class文件,这个Class文件会由Java虚拟机来进行处理。Java虚拟机与Java语言没有什么必然的联系,它只与特定的二进制文件:Class文件有关。因此无论任何语言只要能编译成Class文件,就可以被Java虚拟机识别并执行。
而Jvm可以跑在Linux、Windows、MacOS上,是可以跨平台的。
Class文件格式是怎样的?
示例如图:
可以看到ClassFile具有很强的描述能力,将类文件的各种常量,接口,字段,方法,属性都记录下来了,其中u4、u2表示“基本数据类型”。
- u1:1字节,无符号类型。
- u2:2字节,无符号类型。
- u4:4字节,无符号类型。
- u8:8字节,无符号类型。
Jvm如何读取java语言编写的程序?
Java虚拟机内部,有一个叫做类加载器的子系统,这个子系统用来在运行时根据需要加载类。详情可见我的另一篇类加载器原理的文章。
Jvm的运行过程:
Jvm核心——运行时数据区:
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为为若干不同的数据区域,包括方法区、堆区、虚拟机栈、本地方法栈、程序计数器。
-
程序计数器(非线程共享)
指当前线程正在执行的字节码指令的地址。
我们写一个类,创建一个实例对象,调用一个方法。我们build之后,编译成这个类的class文件。
javap反编译这个文件之后,得到这个方法的偏移量,这个就是程序计数器记录字节码的地址。
为什么Jvm需要一个程序计数器的东西?
因为程序执行到方法的某一行时,由于多线程的执行,另一个线程调度了,这个线程要休息了,就突然停在这一行代码。等系统调度再度执行这个方法时,不会又从第一行执行啊,肯定是接着之前的行号执行,那么这个记录就是程序计数器来做的事情。
-
虚拟机栈(非线程共享)
常常听到说,Jvm是基于栈的,那为什么会这么说呢?为什么程序会按照我们所写的执行顺序去执行呢?因为Jvm会将一个线程中要执行的一个个方法按照顺序,压入到虚拟机栈中。创建线程的时候,会对应创建一个虚拟机栈,会把该线程需要执行的方法按先后人栈,这个方法入栈时就是栈帧。
用于存储当前线程运行方法所需的数据,指令,返回地址。虚拟机栈是线程私有的,各自线程有各个线程的虚拟机栈。
栈帧:
存放的内容包括局部变量表、操作数栈、动态连接、完成出口,对应记录了方法内的局部变量,操作数,多态,方法返回等信息。
虚拟机栈也有大小限制,用-Xss表示。
总结:一个应用会有多个线程,一个线程对应一个虚拟机栈,一个虚拟机栈中会有多个栈帧,一个栈帧即为一个要执行的方法。用栈的弹出方式,所以能让程序有序地执行。就像压入子弹一样,按照顺序一颗一颗地打出去。
-
本地方法栈(非线程共享)
本地方法栈保存的是native方法的信息。当一个Jvm创建的线程调用native方法后,Jvm不再为其在虚拟机中创建栈帧,只是简单地动态链接并直接调用native方法。
-
方法区(线程共享)
存放包括类信息、常量、静态变量、即时编译期编译后的代码。(线程共享)
-
堆区(线程共享)
几乎所有的对象实例都是存储在堆区中的,数组也是存储在堆区中。
为什么设计上将变量存储分为2块区域呢?因为对象、数组这些需要大块空间的边变量,需要频繁创建和回收,而常量、静态变量这些很少回收且不便于回收,所以划分了2块区域各自存储。
堆区也有大小限制,用-Xmx表示。比如声明2G大小堆区:
org.gradle.jvmargs=-Xmx2048m -noverify
Jvm运行时数据区过程总结:
1.申请内存:先要去将堆、栈、方法区等所需的空间大小申请出来。
2.类加载: 将class类中的常量、静态变量、对象的引用等加载进方法区。
3.入栈:将类中的方法按顺序压入虚拟机栈。
- 将new的对象存入堆区,栈帧中对象的引用指向堆中的对象地址。
StackOverflowError出现的原因及解决
一般出现这个问题是因为程序里有死循环调用方法或递归调用方法所产生的。
示例:
class MainActivity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
add(0)
}
private fun add(i: Int) {
var i = i
i++
Log.i("minfo", "i=$i")
add(i)
}
}
调用add()方法的时候,会对add()方法进行压栈操作,将add()运行期数据的数据集保存到栈帧中。
add()递归调用时,都会产生一个新的栈帧区块,这是就会连续的产生新的栈帧区块。当栈内存超过系统配置的栈内存,就会出现java.lang.StackOverflowError异常。
什么是逃逸分析
逃逸分析一种数据分析算法,基于此算法可以有效减少 Java 对象在堆内存中的分配。Hotspot 虚拟机的编译器能够分析出一个新对象的引用范围,然后决定是否要将这个对象分配到堆上。
使用逃逸分析,编译器可以对代码做如下优化:
栈上分配:将堆分配转化为栈分配。如果一个对象在方法内创建,要使指向该对象的引用不会发生逃逸,对象可能是栈上分配的候选。
同步省略:又称之为同步锁消除,如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
示例:
public static void main(String[] args) {
while (true) {
Integer integer = new Integer(111111111);
}
}
将堆内存分配减小,并配置关闭逃逸分析
-XX:+DoEscapeAnalysis : 表示开启逃逸分析
-XX:-DoEscapeAnalysis : 表示关闭逃逸分析
jdk1.6默认逃逸分析是开启的,如果关闭逃逸分析,那么在这么频繁分配对象的情况下,GC会频繁地进行回收对象。当开启逃逸分析,那么该对象发觉会引起频繁的回收,那么会将该对象分配到栈中,因为在栈上分配的对象在方法执行完成之后,会自行清理,减小了垃圾回收器的压力。