码出高效01之走进JVM
前言:对阿里巴巴出版书籍《码出高效》内容的整理,私以为《码出高效》是一本好书,值得多次翻阅学习。
Java如何实现跨平台?
任何计算机领域问题都可以通过增加一个中间层解决。JVM对Java的字节码解释执行,屏蔽对底层操作系统的依赖,达到一次编写,到处运行。
Java所有的指令共200多个,一个字节可以存储256种信息,这样一个字节称为字节码。Java字节码流起始的四个字节非常特殊:“cafe babe”,是一个魔法数,标记这个文件是一个Java文件。
JVM 常见指令
- 加载或存储指令
- 将局部变量加载到操作栈: ALOAD、ILOAD
- 从操作栈顶存储到局部变量表: ASTORE、ISTORE
- 将常量加载到操作栈顶:
- ICONST: 加载 -1 ~ 5 的数
- BIPUSH:Byte Immediate PUSH,加载 -128 ~ 127 的数
- SIPUSH: Short Immediate PUSH, 加载 -32768 ~ 32767
- LDC: Load Constant, 加载 -2147483648 ~ 2147483647 和字符串。
- 运算指令:IADD、IMUL
- 类型转换指令:I2L、D2F
- 对象创建与访问指令
- 创建对象指令:NEW、NEWARRAY
- 访问属性指令:GETFILED、PUTFILED、GETSTATIC
- 检查实例类型指令: INSTANCEOF、CHECKCAST
- 操作栈管理指令
- 出栈操作:POP、POP2
- 复制栈顶元素并压入栈: DUP
- 方法调用
- INVOKEVIRTUAL: 调用对象实例方法
- INVOKESPECAIL: 调用实例初始化方法,私有方法,父类方法
- INVOKESTATIC: 调用类静态方法
- RETURN: 返回VOID类型
- 同步方法: MONITORENTER、MONITOREXIT
- 其他:LINENUMBER
源代码到字节码过程
JAVA源文件 ——> 词法解析 ——> 语法解析 ——> 语义解析 ——> 字节码
字节码执行方式
字节码通过类加载方式加载到JVM环境,才可以执行。执行方式有三种:
- 解释执行:
- 编译执行
- JIT 编译与解释混合执行
- 优势:解释器在启动时先解释执行,省去编译时间。随着时间推进,通过热点代码分析,识别高频的方法调用,循环体,公共模块等,基于JIT动态编译技术,将热点代码转换转换成机器码,直接交给CPU执行。
类加载过程:双亲委派模型(Parents Delegation Model)
计算机模型里任何程序都需要加载到内存中,才可以执行。类加载三个步骤:
- Load:读取类文件,产生二进制流。
- Link:
- 验证:final 是否合格,类型是否正确等
- 准备:为静态变量分配内存,设定默认值
- 解析:确保类与类之间引用正确性,完成内存结构布局
- Init:执行类的构造器方法
类加载器如何定位到具体类并加载读取
- Bootstrap: 通常由与本地操作系统相关的本地代码实现,是最根基的类加载器,负责加载最核心的JAVA类,比如 Object、String、System等。 <jir/lib/rt.jar>
- Extension ClassLoader: 扩展类加载器,加载一些扩展的系统类。比如XML,加密,压缩相关动能类。 <jre/lib/ext/*.jar>
- Application ClassLoader: 应用类加载器,主要加载用户自定义的CLASSPATH路径下的类。
ClassLoader c = TestWhoLoad.class.getClassLoader(); // AppClassLoader
ClassLoader c1 = c.getParent(); // ExtClassLoader
ClassLoader c2 = c1.getParent(); // null
低层次的类加载器不能覆盖高层次已经加载过的类。如果低层次类加载器想加载一个未知的类,要向上逐级询问:“我可以加载这个类吗?”,被询问的类加载器会自我两个问题:第一,这个类我加载过了吗?第二,我可以加载这个类吗?只有当所有高层次的类加载器两个问题都回答为否,才可以让当前的类加载器加载这个位置类。
实现自定义类加载器步骤:继承ClassLoader,重写findClass()方法,调用defineClass()方法。
JVM内存布局
- Heap (堆区)
- 内存区域最大,存储几乎所有的对象,是OOM故障最主要的发源地。通过以下参数可以设定堆内存大小: -Xms256 -Xmx1024,堆空间不断扩容和回缩,会形成不必要的系统压力,建议在生产上设置一样大小。
- 堆分为新生代和老年代,新生代 = 1个Eden区 + 2个Survivor区。
- 对象分配内存规则:
- 大部分对象在Eden区生成,当Eden区放不下时,触发YGC。如果Eden还是放不下,则尝试在老年代分配,如果老年代也放不下,则进行FGC,如果依然放不下,抛出OOM。
- YGC过程:没有被引用的对象直接被清除,存活的对象被送到Survivor区,Survivor区分为S0和S1,每次使用一块空间。YGC时候,将存活的对象复制到未使用的那块空间,然后将正使用的空间完全清除,交换两块空间的使用状态。存活对象有一个计数器,每次YGC的时候计数+1,当超过阈值的后(默认为15),晋升至老年代。如果YGC要移送的对象大于Survivor容量上限,直接移送到老年代。
- Metaspace(元空间)
- 只有在JDK7及之前版本,有Perm区(永久代),在启动时固定大小。FGC时会移动类元信息,如果动态加载的类过多时,容易产生Perm区的OOM。
- JDK8及以后版本,使用元空间替代永久代,元空间在本机分配内存。
- JVM Stack (虚拟机栈) LIFO
- 是描述Java方法执行的内存区域,每个方法从调用开始到执行结束,就是栈帧入栈到出栈的过程。只有位于栈顶的帧才是有效的,称为当前栈帧;正在执行的方法称为当前方法。方法正常执行结束,会跳到另一个栈帧上。StackOverflowError表示请求栈溢出,导致内存耗尽,常出现在递归方法。
- 栈帧包括:局部变量表、操作栈、动态连接、方法返回地址等
- 局部变量表:用来存放局部变量和方法参数的区域。非实例方法,在index【0】存储的是方法的实例引用,随后是参数和局部变量。
- 操作栈:初始状态为空的桶式结构,方法执行过程中,各种指令往栈中写入和提取信息。
- 动态连接:每个栈帧包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。
- 方法返回地址:方法退出过程相当于弹出当前栈帧,退出方式有是三种:
- 返回值压入上层调用栈帧
- 异常信息抛出给能够处理的栈帧
- PC计数器指向方法调用后的下一条指令
- Native Method Stacks (本地方法栈)
- 虚拟机栈“主内”,本地方法栈“主外”,为Native方法服务。调用本地方法,会进入不受JVM控制的世界,本地方法可以通过JNI(Java Native Interface)来访问虚拟机运行时的数据区。JNI使Java具有深度使用操作系统的特性功能,复用非Java代码。
- Program Counter Register (程序计数寄存器)
- 寄存器存储指令相关的现场信息,在并发过程中,任何一个确定的时刻,只会执行某个线程的一条指令,势必会导致经常中断和恢复。
- 每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器存放执行指令的偏移量和行号指示器等,线程的执行或恢复都要依赖程序计数器。
内存区域:
- 线程共享:
- 堆 OutOfMemoryError: Java heap space
- 元空间 OutOfMemoryError: Metaspace
- 线程私有:
- 虚拟机栈 StackOverflowError
- 本地方法栈 StackOverflowError
- 程序计数器
对象实例化
从简单的 Object o = new Object(); 代码进行分析。
- 从字节码的角度进行分析:
stack=2, locals=l, args_size=0
NEW java/ lang/object
DUP
INVOKESPECIAL java/lang/object.<init> ()V
ASTORE_1
LocalVariableTable:
Start Length Slot Name Signature
8 1 0 ref Ljaval lang/object;
- NEW:如果找不到class对象,就进行类加载,类加载成功,在堆中分配内存。分配完毕,则进行零值初始化。分配过程中,注意引用是占据存储空间,是一个变量,占4个字节。这个过程完毕,将指向对象的实例变量压入栈顶。
- DUP:在栈顶复制该变量,这时,在栈顶有两个指向堆内实例对象的引用变量。两个引用变量的目的不同,压至底下的引用用于赋值,或者保存到局部变量表;另一个引用作为句柄调用相关方法。
- INVOKESPECIAL:通过栈顶的引用变量调用方法。是类初始化时执行的方法,是对象初始化执行的方法。
- 从执行步骤的角度分析:
- 确认类元信息是否存在:首先在metaspace内检查要创建的类元信息是否存在,不存在,则使用类加载器以ClassLoader+包名+类名 为key进行查找对应的.class文件。没找到,抛出ClassNotFoundException;如果找到,则进行类加载,并生成对应的Class类对象。
- 分配对象内存:计算对象占用空间大小,如果成员变量是引用变量,仅分配引用变量空间,4个字节大小。接着在堆中划分一块内存给对象。
- 设定默认值:成员变量的值都需要设定为默认值,即不同形式的零值。
- 设置对象头:设置新对象的哈希码、GC信息、锁信息、对象所属的类元信息等。
- 执行init方法:初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
垃圾回收
垃圾回收是为了清除不再使用的对象,自动释放内存。
- 怎么判断对象可以回收:如果一个对象与GC Roots之间没有直接或间接的引用关系,比如:某个失去任何引用的对象,或者两个互为环岛状循环引用的对象。
- 什么对象可以作为GC Roots:类的静态属性中的引用对象、常量引用的对象、虚拟机栈中引用的对象、本地方法栈引用的对象等。
垃圾回收算法:
-
标记-清除 算法:从每个GC Roots出发,依次标记有引用关系的对象,最后将没有标记的对象清除。
- 缺点:造成大量的空间碎片,分配一个较大连续的空间时,容易引发FGC。
-
标记-整理 算法:从每个GC Roots出发,标记存活对象,然后将存活对象整理到空间的一端,形成连续的已使用空间,最后把已使用的空间之外的全部清理掉。
-
Make-Copy 算法:将空间分为两块,每次只使用其中的一块,把存活的对象复制到另一块未激活的空间,然后将已激活的空间中对象清除,并把激活状态互换。堆内存空间分为较大的Eden和两块较小的Survivor,每次只使用Eden和一块Survivor。
- 优点:能够并行的标记和整理,减少了空间的浪费。