一、字节码
为了解决程序因为不同的平台需要编写多套代码的问题,做到一次编写,到处执行的需求。中间码营运而生,即Java中的字节码。在代码的执行过程中,JVM将字节码解释执行,屏蔽对底层操作系统的依赖,JVM也可以将字节码编译执行,如果是热点代码,会通过JIT动态的编译为机器码,提高执行效率。 以下为字节码的主要指令:
1、加载或存储指令
在某个栈帧中,通过指令操作数据在虚拟机栈的局部变量表与操作栈之间来回传输,常见指令如下。
(1)将局部变量加载到操作栈中。ILOAD(将int类型的局部变量压入栈)和ALOAD(将对象引用的局部变量压入栈)。
(2)从操作栈存储到局部变量表。如ISTORE、ASTORE。
(3)将常量加载到操作栈顶,这是极为高频的使用指令:ICONST,BIPIUSH,SIPUSH,LDC等。
2、运算指令
对两个操作栈帧上的值进行运算,并把结果压入操作栈顶。如IADD,IMUL。
3、类型转换指令
显示转换两种不同的数据类型。I2L,D2F
4、对象创建与访问指令:
(1)创建对象指令:NEW,NEWARRAY
(2)访问属性指令:GETFIELD,PUTFIELD,GETSTATIC
(3)检查实例类型指令:INSTANCEOF, CHECKCAST
5、操作栈管理指令
(1)出栈操作。POP
(2)复制栈顶元素并压入栈。DUP
6、方法调用与返回指令
(1)调用对象的实例方法:INVOKEVIRTUAL
(2)调用实例初始化方法、私有方法、父类方法:INVOKESPECIAL
(3)调用类静态方法:INVOKESTATIC
(4)返回类型:RETURN指令
7、同步指令:在JVM中通过使用ACC_SYNCHRONIZED标志同步方法。指令集中有montiorenter和monitorexit支持synchronized语义。
8、除此之外还包含一些额外信息,LINENUMBER存储了字节码与源码行号对应的关系,方便调试的时候正确定位到代码所在行。
字节码必须通过类加载过程加载到JVM环境后,才可以执行。执行有三种模式,第一,解释执行;第二,JIT编译执行;第三,JIT编译与解释混合执行(主流JVM默认执行模式)。混合执行模式的优势在于解释器在启动时先解释执行,省去编译时间。随着时间推进,JVM通过热点代码统计分析,识别高频的方法调用、循环体、公共模块等,基于强大的JIT动态编译技术,将热点代码转换成机器码,直接交给CPU 执行。JIT的作用是将Java字节码动态地编译成可以直接发送给处理器指令执行的机器码。 简要流程如下:
二、类加载过程
类加载器相关
上边链接为有关类加载器的一些解释。
此外还有几点需要注意的地方:
1、在JDK9中Class类下的newInstance()已经置为过时,应该试用getDeclaredConstructor().newInstance()。Class类下的newInstance()为弱类型,只能调用无参构造方法。如果没有无参构造函数,则会抛出异常。
2、需要自定义类加载器的情况
(1) 隔离加载类。在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。
(2) 修改类加载方式。类的加载模型并非强制,除Bootstr叩外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载。
(3)扩展加载源。比如从数据库、网络,甚至是电视机机顶盒进行加载。
(4)防止源码泄露。java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。
3、实现自定义类加载器的步骤:继承ClassLoader,重写findClass(),调用defineClass()方法。findClass()方法主要是读取字节码(.class文件),defineClass()方法则是将读取到的字节码文件加载到虚拟机。
执行结果如下:
三、内存布局
以下为经典的JVM内存布局。
1、堆区(Heap)
Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用。
堆分成两大块:新生代和老年代。
对象产生之初在新生代,步入暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象。
新生代:1个Eden区+ 2个 Survivor区。绝大部分对象在Eden区生成,当Eden区装填满的时候,会触发Young Garbage Collection,即YGC。垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收。
依然存活的对象会被移送到Survivor区, 这个区真是名副其实的存在。Survivor区分为S0和S1两块内存空间, 送到哪块空间呢?每次YGC的时候,它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。
如果YGC要移送的对象大于Survivor区容量的上限,则直接移交给老年代。
假如一些没有进取心的对象以为可以一直在新生代的 Survivor区交换来交换去,那就错了。每个对象都有一个计数器,每次YGC都会加 l。 -XX:MaxTenuringThreshold参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升至老年代。如果该参数配置为1,那么从新生代的Eden区直接移至老年代。默认值是15, 可以在Survivor区交换14次之后,晋升至老年代。
2、元空间(MetaSpace)
在JDK8中,元空间的前身Perm区被淘汰,只有JDK7及之前的版本才有Perm区,译为永久代。区别于永久代,在JDK8中,perm中的所有字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。
3、虚拟机栈(JVM Stack)
JVM中的虚拟机栈是描述Java方法的内存区域,他是线程私有的。在活动线程中,只有位于栈顶的帧才是有效的,称为当前帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。
栈帧包括局部变量表,操作栈,动态连接,方法返回地址等。
(1)局部变量表:存放方法参数和局部变量的地方。
(2)操作栈:初始状态为空的筒式结构栈,在方法执行过程中,会有各种指令往栈中写入和提取信息。
(3)动态链接。每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程中的动态链接。
(4)方法返回地址。方法执行时有两种退出情况:第一,正常退出,第二,异常退出。无论何种推出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前帧,退出可能有三种方式:
- 返回值压入上层调用栈帧。
- 异常信息抛给能够处理的栈帧。
- pc计数器指向方法调用后的下一条指令。
4、本地方法栈 本地方法栈主要为native本地方法服务。
5、程序计数寄存器
寄存器存储指令相关的现场信息, 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。 程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常。
四、垃圾回收
垃圾回收的主要目的是清楚不再使用的对象,自动释放内存。
那么GC是如何判断对象被回收了呢?为了判断对象是否存活,JVM引入了JC Roots
如果一个对象与GC Roots之间没有直接或间接的引用关系,比如某个失去任何引用的对象,或者两个互相环岛状循环引用的对象等,判决这些对象“死缓" 是可以被回收的。
以下为垃圾回收的相关算法:
标记一清除算法, 该算法会从每个GC Roots出发,依次标记有引用关系的对象,最后将没有被标记的对象清除。但是这种算法会带来大量的空间碎片,导致需要分配一个较大连续空间时容易触发FGC。
标记一整理算法, 该算法类似计算机的磁盘整理,首先会从GC Roots出发标记存活的对象,然后将存活对象整理到内存空间的一端,形成连续的已使用空间,最后把已使用空间之外的部分全部清理掉,这样就不会产生空间碎片的问题。
Mark-Copy算法, 为了能够并行地标记和整理,将空间分为两块,每次只激活其中一块, 垃圾回收时只需把存活的对象复制到另一块未激活空间上,将未激活空间标记为已激活,将已激活空间标记为未激活,然后清除原空间中的原对象。
堆内存空间分为较大的Eden和两块较小的Survivor,每次只使用Eden和Survivor区的一块。这种情形下的"Mark-Copy"减少了内存空间的浪费。"Mark-Copy"现作为主流的YGC算法进行新生代的垃圾回收。
垃圾回收器(Garbage Collector)是实现垃圾回收算法并应用在JVM环境中的内存管理模块。 垃圾回收器主要有Serial,CMS,GI等数十种。
PS:关于JVM,只是就本书做了整理。还需要接下来更加深入细致的学习。