JVM阅读笔记-初学

深入理解JVM

第一章 走近JAVA

第二章 Java内存区域与内存溢出异常

第三章 垃圾收集器与内存分配策略

第六章 类文件结构

第七章 虚拟机类加载机制

Matrix 相关


第二章 Java内存区域与内存溢出异常

一、运行时的数据区域

图片1.png

http://androidxref.com/9.0.0_r3/s?defs=BaseDexClassLoader&project=libcore

程序计数器

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个具体确定的时刻,一个处理器内核只会执行一条线程中的指令,因此,为了不同线程切换之后可以恢复原先的状态,每个线程都需要一个程序计数器,各个线程的程序计数器之间不会相互影响,独立存储,这类内存是线程私有的内存。

如果执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节指令的地址,如果是Native方法, 则值为空(undefined),且程序计数器是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

Java虚拟机栈所占有的内存空间也就是我们平时所说的“栈内存”,并且也是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时,都会创建一个栈帧,用于存储局部变量表(基本数据类型,对象的引用和returnAddress类型)、操作数栈、动态链接、方法出口等信息。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。对于Java虚拟机栈,有两种异常情况:

1)如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;

2)如果虚拟机栈在动态扩展时,无法申请到足够的内存,就会抛出OutOfMemoryError;(动态扩展的方法有Segmented stack双向链表,可以简单理解成一个双向链表把多个栈连接起来,一开始只分配一个栈,这个栈的空间不够时,就再分配一个,用链表一个一个连起来。Stack copying就是在栈不够的时候,分配一个更大的栈,然后把原来的栈复制过去)

本地方法栈和Java堆

本地方法栈和虚拟机栈所发挥的作用非常相似,它们之间的区别主要是【虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务的,而本地方法栈则为虚拟机使用到的Native方法服务】。与虚拟机栈类似,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆是Java虚拟机所管理的内存中最大的一块。Java堆在主内存中,是被所有线程共享的一块内存区域,其随着JVM的创建而创建,【堆内存的唯一目的是存放对象实例和数组。同时Java堆也是GC管理的主要区域】。

Java堆物理上不需要连续的内存,只要逻辑上连续即可。如果堆中没有内存完成实例分配,并且也无法再扩展时,将会抛出OutOfMemoryError异常。《?》

运行时常量池

【图中没有运行时常量池了】
还有一项信息是常量表,用于存放编译期生成的各种字面常量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放(JDK1.7开始,常量池已经被移到了堆内存中了)。也就是说,这部分内容,在编译时只是放入到了常量池信息中,到了加载时,才会放到运行时常量池中去。运行时常量池县归于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的是String类的intern()方法。

方法区

也是线程公有的一个部分,用来存储已经被加载过的类信息,常量,静态变量


二、新建对象

Java的语言层面上,创建对象(克隆,反序列化)都是一个new关键字,在虚拟机中是怎样工作的呢?

首先虚拟机在解析到new指令的时候,会去检查参数是否可以定位到一个类的符号引用,并且检查这个类是否已经被加载、解析、初始化过,如果没有的话会先进行类加载的操作。

在此之后会为新生的对象进行内存的分配工作,分配的方式根据不同JVM的堆中内存存储方式不同也会不一样(空闲列表/指针碰撞)。

指针碰撞:假设Java堆中的内存是绝对规整的,所有用过的内存放在一边,没有用过的内存放在另一边,中间放着一个指针作为分界点的指示器,那么分配内存就是移动这个指针一个对象大小的距离(在给新建对象分配内存的时候,会占用多少内存是一个已知信息)

空闲列表:如果Java堆中的内存是并不规整的,虚拟机就需要维护一个列表,哪些内存块是可用的,在分配的时候哦找到一块足够大的空间划分给对象,并更新列表上的记录。

JAVA堆是线程公有的,所以在多线程分配内存时也会遇到并发的问题,那么这个问题的解决方案也有两种,一种是CAS配上失败重试,另一个就是TLAB,Java堆首先给每个线程分配一块内存,之后每个线程会优先在Tlab上进行对象的内存分配。(-XX:+UseTLAB)

CAS: Compare and Swap, 比较并交换,整个Cucurenct包中,CAS理论是它实现的基石。

CAS操作包括3个操作数------内存位置(V),预期原值(A), 新值(B),如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更换为新值(原子操作),否则,处理器不做任何操作,不论任何情况,它都会在CAS指令返回该内存位置的值【底层使用总线锁实现,】(CAS只保证操作无错误,不代表一定会成功,比如ABA问题)

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)如果使用TLAB,这个操作可以提前到TLAB分配的时候,再之后,对于虚拟机来说,一个新建对象已经完成了,对于语言层面来说,class的方法还没有执行到,(在这里可以先暂时将 理解为Java的构造函数),在这之后一个可用的对象才真正的生成。

栈上分配和逃逸分析

上面所谈到的内存分配都只设计Java堆的内存分配,书本也因为JDK版本原因没有涉及到逃逸分析的相关内容,这里自己搜资料总结了一下。在JDK8之后的版本默认开启逃逸分析(栈上分配的前提),8以下(-XX:+DoEscapeAnalysis)可以进行逃逸分析。

栈上分配:我们都知道Java中的对象都是在堆上分配的,而垃圾回收机制会回收堆中不再使用的对象,但是筛选可回收对象,回收对象还有整理内存都需要消耗时间。如果能够通过逃逸分析确定某些对象不会逃出方法之外,那就可以让这个对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

在一般应用中,如果不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了。
对象逃逸有以下几种情况:
···
public class EscapeAnalysis {

 public static Object object;
 
 public void globalVariableEscape(){//全局变量赋值逃逸  
     object =new Object();  
  }  
 
 public Object methodEscape(){  //方法返回值逃逸
     return new Object();
 }
 
 public void instancePassEscape(){ //实例引用发生逃逸
    this.speak(this);
 }
 
 public void speak(EscapeAnalysis escapeAnalysis){
     System.out.println("Escape Hello");
 }

}
···
全局变量赋值逃逸

方法返回值逃逸

实例引用发生逃逸

线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量

JVM配置[关闭逃逸分析]:

-server -Xmx10m -Xms10m -XX:-DoEscapeAnalysis -XX:+PrintGC
/**
 * Created by huangwt on 2019/3/8.
 */
public class StackAlloc {
    private static void alloc() {
        byte[] tmpAlloc = new byte[20];
    }
    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i ++) {
            alloc();
        }
    }
}

在上面这段Code中,我们生成的长度为20的tmpAlloc数组并没有被外界引用,但是查看GC 日志可以发现
【这里的GC均是Mijor GC】

[GC 2560K->568K(10240K), 0.0057678 secs]
[GC 3128K->576K(10240K), 0.0006989 secs]
[GC 3136K->584K(10240K), 0.0006238 secs]
[GC 3144K->584K(10240K), 0.0019912 secs]
[GC 3144K->584K(10240K), 0.0012139 secs]
[GC 3144K->584K(9216K), 0.0010486 secs]
[GC 2120K->656K(9728K), 0.0007815 secs]
[GC 2192K->648K(9728K), 0.0002554 secs]
[GC 2184K->696K(9728K), 0.0001980 secs]
...

发生了频繁的GC,说明内存分配在Java堆上而非栈上,这里的tmpAlloc生命周期应该是跟随alloc任务出栈而结束,但是却没有结束,是因为我们关闭了逃逸分析,再次打开发现GC一次都没有触发,说明内存分配在栈上跟随方法一同被出栈了。

这里举的方法都是这里举得例子是虚拟机栈的栈上分配,有关本地方法栈可以查看这个回答https://stackoverflow.com/questions/161053/which-is-faster-stack-allocation-or-heap-allocation

code optimization:虽然不清楚DVM/ART的工作机制,但是有关对象的生命周期我们一般希望在不影响频繁GC的情况下越短越好,我们的code有很多可以优化为local variable,而不需要被整个对象所持有的对象,Lint可以检查出来。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局分为3个区域:对象头,实例数据和对齐填充。
对象头包括两个部分的数据,第一部分是“mark word”包含 hashcode,GC分代年龄(之后会说到),锁状态标志,线程持有的锁(对象头的锁相关在书的第十三章会详细讲解,所以我没有去查资料),偏向线程ID,偏向时间戳等等。
第二部分是类型指针,即指向对象指向类元数据的指针,虚拟机来确定这个对象是哪个类的实例。 另外,如果对象是一个Array类型,还需要一个int类型的长度来表示数组的长度
实例数据就是字面意思
对齐填充,不一定存在,例如HotSpot VM的内存管理系统要求对象起始地址必须是8字节的整数倍,有点类似Android的ZipAlign机制,总之作用就是用来方便寻址。

Integer = (8 + 4) + 4(int)
Long = (8 + 4) + 8(long) + 4(padding)
优化空间:可以被替换为元数据类型的不要使用包装类。


第三章 垃圾回收器和内存分配策略

线程私有的内存区域会随着线程的生命周期自动的被回收。
线程公有的Java堆 & 方法区,只有在运行时才知道需要具体创建多少个对象,这部分内存的分配和回收都是动态的,所以需要GC配合垃圾回收。本章分为以下几点内容分享

1、如何判断对象需要进行回收
2、垃圾收集的算法
3、JVM的具体实现(HotSpot-JVM)

4、垃圾收集器
5、内存分配&回收策略

一、如何判断对象是否死去

1、引用计数

在Fix内存泄漏之前我对此并不了解,现在大家应该都知道在Android中是与可达性分析算法相关。
那么现有的常见的有引用计数算法,故名思议,对象中添加一个引用计数器,每当有地方引用她时,计数器+1 ,反之则-1。
Python使用的主流回收方式就是引用计数,和所有面向对象的语言一样,Python的PyObject的结构体如下所示,这个ob_refcnt就是引用计数【这里选的是CPython】。
前文也介绍过JVM对象的组成结构了,其中并没有类似字段,所以Java采用的并非此算法,引用计数的优点就是简单,缺点就是浪费内存&不好解决循环引用的问题,前者倒无所谓,要解决后者肯定需要另外的对象来维护这件事或者Object添加新的字段来维护,这就得不偿失了。

 typedef struct_object {
 int ob_refcnt;
 struct_typeobject *ob_type;
} PyObject;

2、可达性算法

另一个常见的就是可达性分析算法,将某些对象称为GC-ROOT,从GC-ROOT走过的路径即是引用链,而GC不达的对象即为可回收的对象,像这种,B,C即为可以回收的对象。
那么什么是GC ROOT呢?
在Java中,可以作为GC ROOT的有以下几种:

虚拟机栈中引用的对象
方法区类静态属性引用的对象
方法区常量引用的对象
本地方法栈JNI引用的对象

image.png

java中的主流虚拟机HotSpot采用可达性分析算法来确定一个对象的状态,那么HotSpot在具体实现该算法时采用了哪些结构?

使用OopMap记录并枚举根节点

HotSpot首先需要枚举所有的GC Roots根节点,虚拟机栈的空间不大,遍历一次的时间或许可以接受,但是方法区的空间很可能就有数百兆,遍历一次需要很久。更加关键的是,当我们遍历所有GC Roots根节点时,我们需要暂停所有用户线程,因为我们需要一个此时此刻的”虚拟机快照”,如果我们不暂停用户线程,那么虚拟机仍处于运行状态,我们无法确保能够正确遍历所有的根节点。所以此时的时间开销过大更是我们不能接受的。

基于这种情况,HotSpot实现了一种叫做OopMap的数据结构,这种数据结构在类加载完成时把对象内的偏移量是什么类型计算出,并且存放下位置,当需要遍历根结点时访问所有OopMap即可。

用安全点Safepoint约束根节点

如果将每个符合GC Roots条件的对象都存放进入OopMap中,那么OopMap也会变得很大,而且其中很多对象很可能会发生一些变化,这些变化使得维护这个映射表很困难。实际上,HotSpot并没有为每一个对象都创建OopMap,只在特定的位置上创建了这些信息,这些位置称为安全点(Safepoints)。

从线程角度看,safepoint可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停,比如发生GC时,需要暂停暂停所以活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待GC结束。【?】

SafePoint.cpp 实现 http://hg.openjdk.java.net/jdk7u/jdk7u/hotspot/file/e1b1da173e19/src/share/vm/runtime/safepoint.cpp

引用

Java中的引用目前有以下几种

强引用(SoftReference)

强引用就是正常的使用方式,即便系统抛出OOM也不会对对象进行回收

软引用

软引用是指当内存不够时才会对软引用进行回收,只要内存还充足,就不会对其进行回收

弱引用

弱引用则是指不论内存是否足够,只要gc了就会回收

    public Reference soft;
    public Reference weak;

    soft = new SoftReference<>(new Temp());
    weak = new WeakReference<>(new Temp());
        Observable.just(new Object())
                .observeOn(Schedulers.io())
                .subscribe(new Consumer() {
                    @Override
                    public void accept(Object o) throws Exception {
                        Log.d("--------Object state", "soft" + (soft.get() != null ? "生存" : "毁灭"));
                        Log.d("--------Object state", "weak" + (weak.get() != null ? "生存" : "毁灭"));
                        System.gc();
                        Log.d("--------Object state", "soft" + (soft.get() != null ? "生存" : "毁灭"));
                        Log.d("--------Object state", "weak" + (weak.get() != null ? "生存" : "毁灭"));
                    }
                });
 
 
2019-03-08 14:46:28.161 13532-13558/com.example.hwt.hotfixsample D/--------Object state: soft生存
2019-03-08 14:46:28.161 13532-13558/com.example.hwt.hotfixsample D/--------Object state: weak生存
2019-03-08 14:46:48.162 13532-13558/com.example.hwt.hotfixsample D/--------Object state: soft生存
2019-03-08 14:46:48.163 13532-13558/com.example.hwt.hotfixsample D/--------Object state: weak毁灭

可以看到,在进行GC之后,软引用的对象还生存着,而弱引用的对象已经挂了

虚引用

虚引用(Phantom Reference):任何时候都可以被 GC 回收,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否存在该对象的虚引用,来了解这个对象是否将要被回收。可以用来作为 GC 回收 Object 的标志。

public class PhantomReference extends Reference {

    /**
     * Returns this reference object's referent.  Because the referent of a
     * phantom reference is always inaccessible, this method always returns
     * null.
     *
     * @return  null
     */
    public T get() {
        return null;
    }
...
}

Code optimization
有关软引用,由于根据内存动态回收的特性很多人会使用来作为Cache,但是Android并不推荐这么做,在SoftReference的备注中我们可以看到这么一段话,推荐使用LruCache来代替SoftReference作为Cache的用法

 * 

Most applications should use an {@code android.util.LruCache} instead of * soft references. LruCache has an effective eviction policy and lets the user * tune how much memory is allotted.

被回收了吗?

C 有析构函数在被回收时调用,那么Java被回收时会有通知吗?有什么操作可以避免对象被GC回收吗?
答案是有的

public Object {
    protected void finalize() throws Throwable { }
}

Object对象有一个finalize的方法,在重写了finalize的情况下会首先调用finalize方法并将对象加入一个名为F-QUEUE的队列中, 如果在finalize方法中重新建立链接,对象将被拯救,并在下一次GC时移除“即将回收”的集合。

换句话说,如果对象在回收时又报上了大腿,比如将自己赋值给某个static引用,则这个对象会被避免这次GC。

回收方法区

前文中有提到过方法区主要存储了类信息,静态信息之类的内容,这一部分的内存在JVM手册中是允许不做GC处理的,但是不代表这块区域不会进行回收,通常在频繁使用自定义ClassLoader时才会导致有废弃的类与类信息,如果判断为不需要的类信息之后可以对类进行卸载,但是Android中不论DVM虚拟机还是ART架构貌似都不支持类的卸载。

垃圾回收算法

各个平台虚拟机实现的方式各不相同,这里只介绍一些常见的思想和算法(后面所举的例子都是基于JDK1.7,每个版本的都不太一样)

标记-清除算法

标记过程之前讲述对象标记判定时也提到过,首先先标记出需要被回收的对象,再统一进行回收,这样做的优点是操作简单,但是缺点也非常明显。
一是标记和清除过程效率并不高,会对整个java堆的对象进行遍历。
二是标记清除之后会产生大量不连续的内存碎片。


标记清除算法

复制算法

复制算法是先将可用内存区一分为二,每次GC的时候将A片区的存活对象依次迁移到B片区,并一次性将A片区清空,内存的分配是B片区自然是连续的,实现简单,运行高校,但是天生就少了一半内存狠明显不太合理,不过算法是可以进行优化的,现代的商业虚拟机都采用复制算法来回收新生代,因为绝大多数的对象是朝生夕死的,也就是说,每次Gc之后还能存活的对象实际上很少,所以并不需要一半一半的来分区,在JDK1.7版本中,是分为一块较大的Eden空间和两块较小的Survivor空间,比例默认是8 1 1,这个可以在VM OPTIONS里自己设置。


复制算法

那么如果存活对象所占空间> 10%,B片Survivor空间放不下怎么办?这个时候会依赖老年代进行分配担保。

标记-整理算法

复制算法对空间的较低利用率很明显不适合 相对稳定,需要较少Gc的老年代,所以老年代一般不会选择复制算法,标记整理算法先对于1 标记 清除有什么不一样呢?他在标记之后会先对所有依然存活的对象往一个地方移动,然后清理掉边界以外的内存。

image.png

我们可以发现,没有一种完美的算法,每个算法都有他的缺点和优点,所以现行的虚拟机都会进行分代收集,即老年代和新生代,部分对方法区进行回收的也许还有个持久代,这里不讨论,只讨论Java堆的垃圾回收。

常见的GC处理器
image.png

并行是指多个GC线程同时回收但是此时用户线程还是处在暂停状态

并发是指GC线程与用户线程并行,即在进行垃圾回收的同时用户可以操作

CMS则是一个追求响应时间放弃部分系统吞吐量的垃圾回收算法,具体几种垃圾收集器的工作流程可以看书或者查一下。

GC日志的查看

jvm参数配置

 -XX:+PrintGCDetails
/**
 * Created by huangwt on 2019/3/8.
 */
public class StackAlloc {
    private static int MB_1 = 1024*1024;

    public static void main(String[] args) {
        byte[] a = new byte[MB_1];
        byte[] b = new byte[MB_1];
        byte[] c = new byte[MB_1 * 4];
    }
}
[GC [PSYoungGen: 2531K->504K(3072K)] 2531K->1600K(10240K), 0.0018824 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 3072K, used 1607K [0x00000000ffc80000, 0x0000000100000000, 0x0000000100000000)
  eden space 2560K, 43% used [0x00000000ffc80000,0x00000000ffd93e70,0x00000000fff00000)
  from space 512K, 98% used [0x00000000fff00000,0x00000000fff7e010,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 7168K, used 5192K [0x00000000ff580000, 0x00000000ffc80000, 0x00000000ffc80000)
  object space 7168K, 72% used [0x00000000ff580000,0x00000000ffa92020,0x00000000ffc80000)
 PSPermGen       total 21504K, used 3223K [0x00000000fa380000, 0x00000000fb880000, 0x00000000ff580000)
  object space 21504K, 14% used [0x00000000fa380000,0x00000000fa6a5c80,0x00000000fb880000)

最终打印的GC日志字段含义是什么呢? 首先可以看到JAVA堆被分成这几个区域,新生代(PSYoungGen)
老年代(OldGen),和永久代(JDK8已经废弃被元空间替代,可以自行了解)。
首先看新生代,被划分为Eden, From, To 三个内存空间,2560KB代表了Eden区的大小,from to两块survivor空间大小一致均为512K ,Eden和Survivor区的比例可以用参数配置修改,同样新生代与老年代的比例也可以设置。所以新生代的可用区域为eden区 + 一块Survivor区的大小
老年代则是一块7168KB的内存区域。
code首先新建了2个1MB大小的对象,创建对象会优先在新生代创建,所以在第一次GC前会优先在Eden区和一块Survivor区域分配空间。【不分配任何对象时Eden区也会被占用一部分空间,这边总内存为10M时占了1400KB】, 所以这边生成a对象时会直接在新生代分配,随后分配b对象时,新生代空间不足会发生一次minorGC【不带FULL GC字样的即为只发生在新生代】,
PSYoungGen: 2531K->504K(3072K) 这个代表了新生代从占用2531KB变为了504KB(放在了From区域),2531K->1600K(10240K) 是指整个新生代加老年代占用内存从2531KB变为了1600KB。后面的时间是指GC消耗的时间。这次GC之后对象a会由分配担保机制被送入了老年代,而b被分配在了新生代。
在此之后的C对象,大对象则会直接进入老年代,虚拟机提供了参数

-xx:PretenureSizeThreshold = 3145728

来配置大于多大的对象会直接分配到老年代。
Code optimization:千万要避免 创建朝生夕死的大对象

第四章 & 第五章

讲了JAVA工具的使用,还有一些具体的调优案例,可以看书。

第七章 虚拟机的类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最后形成可以被虚拟机直接使用的Java类型,着就是类加载机制。
在Java中,类型的加载、连接和初始化都是在程序的运行期间完成的,虽然会让类加载增加了一些性能开销,但是灵活性会很高。例如 编写一个面向接口的代码,到运行期再指定其具体的实现类,另外用户可以通过Java预定义和自定义类加载器,让本地程序可以在运行时从网路或者其他地方加载一个二进制流作为程序代码的一部分动态组装。

类加载的时机

类从被加载开始,到卸载出内存为止,他的生命周期包括:
加载 验证 准备 解析 初始化 使用 卸载


image.png

【连接 包括 验证 准备 解析3个步骤】
虚拟机并没有规范什么时候一定要执行类的加载操作,但是严格规范了什么时候必须立即对类进行初始化操作,而之前的步骤一定会走在类初始化之前

  1. 遇到new /getstatic/setstatic/invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化工作。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化工作。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则先需要对父类进行初始化。
  4. 当虚拟机启动时,用户需要指定一个启动主类(main方法),这个类会被初始化
  5. invoke的静态方法属于的类如果没有进行过初始化,则需要先触发其初始化工作。
/**
 * Created by huangwt on 2019/3/11.
 */
class ClassInitTest {

    static {
        System.out.println("ClassInitTest initialized!");
    }

    static final Integer ORZ_VALUE = 1;

    static class ClassInitSubTest extends ClassInitTest {
        static {
            System.out.println("ClassInitTestSub initialized!");
        }
    }
}


public class Main {

    public static void main(String[] args) throws InterruptedException {
        System.out.println(ClassInitTest.ClassInitSubTest.ORZ_VALUE);
    }
}

这段代码的输出会是


输出

可以发现通过子类去调用父类的静态字段并不会导致子类的初始化,只会带来父类的初始化
,当然,至于是否初始化子类虚拟机的规范中没有明确的规定,所以取决于各个虚拟机制定的规则有所不同。

第二段代码

public class Main {

    public static void main(String[] args) throws InterruptedException {
        ClassInitTest.ClassInitSubTest[] tmp = new ClassInitTest.ClassInitSubTest[10];
    }
}

这段代码最后的结果将是没有一个类被初始化了,为什么会这样呢,看一下类的反编译

public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.InterruptedException;
    Code:
       0: bipush        10
       2: anewarray     #2                  // class ClassInitTest$ClassInitSubTest
       5: astore_1
       6: return
}

在新建的时候调用了anewarray字节码指令而非规范中所提及的几种主动引用,所以没有发生具体类的初始化。【Q:TraceClassload的时候会发现loaded,与书中不太一致】

第三段Code

class ClassInitTest {
     public static final String s = "test";
}

public class Main {

    public static void main(String[] args) throws InterruptedException {
        System.out.println(ClassInitTest.s);
    }
}

之外再将原先的ORZ_VALUE的类型从包装类Integer换成int,可以发现并不会调用到类的加载,这是因为在编译器虚拟机已经将"test"字符串存到了常量池中,Main.class直接持有常量池的引用,

public class Main {
    public Main() {
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("test");
    }
}

类加载的过程

加载

加载是类加载过程的一个阶段,在这个阶段,虚拟机需要完成以下三件事情:

1)通过一个类的全限定名来获取定义此类的二进制流
2)将这个字节流所代表的静态存储结构转化为方法区运行时结构数据
3)在内存中生成一个代表这个类,作为方法区这个类的各种数据的访问入口

https://docs.oracle.com/javase/specs/jvms/se11/html/jvms-5.html#jvms-5.3.3
【对于数组类而言,If C is not an array class, it is created by loading a binary representation of C (§4 (The class File Format)) using a class loader. Array classes do not have an external binary representation; they are created by the Java Virtual Machine rather than by a class loader.】
虚拟机定义的三件事情并不算具体,所以灵活性极高,在历程中出现了许多有关类加载的操作:
1、从ZIP包读取,日后成为JAR,AAR之类格式的基础
2、从网络中获取 (热修复)
3、运行时计算生成,比如动态代理
等等
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区中,方法区中的数据存储格式由虚拟机实现自行定义,然后在内存中实例化一个Class类的对象【没有明确规定存储在Java堆中】。

验证

验证是连接阶段的第一步,主要是为了确保Class文件的字节流包含的信息是否符合虚拟机要求,丙炔不会危害自身的安全。
验证阶段分为以下四个步骤:
1、文件格式验证

  // first verification pass - validate cross references and fixup class and string constants
  for (index = 1; index < length; index++) {          // Index 0 is unused
    jbyte tag = cp->tag_at(index).value();
    switch (tag) {
      case JVM_CONSTANT_Class :
        ShouldNotReachHere();     // Only JVM_CONSTANT_ClassIndex should be present
        break;
      case JVM_CONSTANT_Fieldref :
        // fall through
      case JVM_CONSTANT_Methodref :
        // fall through
      case JVM_CONSTANT_InterfaceMethodref : {
        if (!_need_verify) break;
        int klass_ref_index = cp->klass_ref_index_at(index);
        int name_and_type_ref_index = cp->name_and_type_ref_index_at(index);
        check_property(valid_cp_range(klass_ref_index, length) &&
                       is_klass_reference(cp, klass_ref_index),
                       "Invalid constant pool index %u in class file %s",
                       klass_ref_index,
                       CHECK_(nullHandle));
        check_property(valid_cp_range(name_and_type_ref_index, length) &&
                       cp->tag_at(name_and_type_ref_index).is_name_and_type(),
                       "Invalid constant pool index %u in class file %s",
                       name_and_type_ref_index,
                       CHECK_(nullHandle));
        break;
      }
      case JVM_CONSTANT_String :
        ShouldNotReachHere();     // Only JVM_CONSTANT_StringIndex should be present
        break;
      case JVM_CONSTANT_Integer :
        break;
      case JVM_CONSTANT_Float :
......

这边贴了HotSpot虚拟机的文件检查的一小部分的代码,可以看到是对class文件的常量池进行检查,比如是否以CafeBaby作为文件开头,版本号是否在当前虚拟机的处理范围之内,常量池中是否由不被支持的常量类型,等等等等。

2、元数据验证
对类的元数据信息进行语义校验,比如:
这个类是否有父类,父类是否继承了final class, 类是否实现了父类抽象方法和接口方法,类中字段和父类是否产生矛盾。

3、字节码验证
主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
例如:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现上一步往栈中压了一个int类的数字,而后pop时候当作long类型使用。
保证跳转指令不会跳到方法体以外的字节码指令上
保证类型转换是安全有效的。
如果一个类方法体的字节码通过了字节码的验证,也不能代表他是安全的。
【停机问题】,没有一段程序可以判断程序是否可以在有限时间之内结束运行。

4、符号引用验证
符号引用验证发生在解析阶段的最后一个部分,通常检查以下几个部分
通过字符串的全限定名能否找到类
符号引用中的类、字段、方法的访问性能否被当前类直接访问

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中分配。例如

  public static int value = 123;

在当前类被初始化的时候,value的值会先被赋0值,然后将putstatic放在类的static方法中,也就是类的Cinit方法中,对value重新赋值为123;

public class Main {
  public static int a;

  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.InterruptedException;
    Code:
       0: return

  static {};
    Code:
       0: bipush        123
       2: putstatic     #2                  // Field a:I
       5: return
}

不过如果给a设定了final修饰符,则会在初始化的时候直接赋予123值。

解析

解析是虚拟机将符号引用替换为直接引用的过程。

/**
 * Created by huangwt on 2019/3/11.
 */
public class X {
    public void foo() {
        bar();
    }

    public void bar() { }
}

现在有这么一个类X。他编译出来的Class文件基本如下

Classfile /E:/untitled1/JVMRead/out/production/JVMRead/X.class
  Last modified 2019-3-11; size 372 bytes
  MD5 checksum 63e83d9ddf2d3c4081f4a742f0e5c2ab
  Compiled from "X.java"
public class X
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#16         // java/lang/Object."":()V
   #2 = Methodref          #3.#17         // X.bar:()V
   #3 = Class              #18            // X
   #4 = Class              #19            // java/lang/Object
   #5 = Utf8               
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               LX;
  #12 = Utf8               foo
  #13 = Utf8               bar
  #14 = Utf8               SourceFile
  #15 = Utf8               X.java
  #16 = NameAndType        #5:#6          // "":()V
  #17 = NameAndType        #13:#6         // bar:()V
  #18 = Utf8               X
  #19 = Utf8               java/lang/Object
{
  public X();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 4: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LX;

  public void foo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokevirtual #2                  // Method bar:()V
         4: return
      LineNumberTable:
        line 6: 0
        line 7: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LX;

  public void bar();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 9: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   LX;
}
SourceFile: "X.java"

紧接着,我们查看foo()方法
它的第一条字节码指令为:

1: invokevirtual #2  // Method bar:()V

这在Class文件中的实际编码为

[B6] [00 02]

0xB6是invokeVirtual指令的操作吗,后面的00 02则是操作数,用于指定要调用的目标方法, 也就是常量池中编号为#2的内容,我们再找到#2的内容

2 = Methodref          #3.#17         // X.bar:()V

是一个MethodRef,它在class文件中的实际编码为

[0A] [00 03] [00 11]

和上面相同,0A代表了CONSTANT_MethodRef_Info的tag,后面的两个内容则是class_index和name_and_type_index,找一下对应的#3 和#17号常量池内容

3 = Class              #18            // X
17 = NameAndType        #13:#6         // bar:()V

和上面一样,3指向了 #18,而#18常量是类X

17 指向了 #13 和 #6,分别是

13 = Utf8               bar
6 = Utf8               ()V

那么我们把刚刚的一系列操作拼凑起来看看结果

  #2 Methodref X.bar:()V
     /                     \
#3 Class X       #17 NameAndType bar:()V
    |                /             \
#18 Utf8 X    #13 Utf8 bar     #6 Utf8 ()V

由此可以看出,Class文件中的invokevirtual指令的操作数经过几层间接之后,最后都是由字符串来表示的。这就是Class文件里的“符号引用”的实态:带有类型(tag) / 结构(符号间引用层次)的字符串。

然后再看JVM里的“直接引用”的样子。由于书里面 有关引用讲的都一带而过,这里举一个JVM远古版本的实现

HObject             ClassObject
                       -4 [ hdr            ]
--> +0 [ obj     ] --> +0 [ ... fields ... ]
    +4 [ methods ] \
                    \         methodtable            ClassClass
                     > +0  [ classdescriptor ] --> +0 [ ... ]
                       +4  [ vtable[0]       ]      methodblock
                       +8  [ vtable[1]       ] --> +0 [ ... ]
                       ... [ vtable...       ]

在刚加载好一个类的时候,Class文件里的常量池和每个方法的字节码(Code属性)会被基本原样的拷贝到内存里先放着,也就是说仍然处于使用“符号引用”的状态;直到真的要被使用到的时候才会被解析(resolve)为直接引用。假定我们要第一次执行到foo()方法里调用bar()方法的那条invokevirtual指令了。此时JVM会发现该指令尚未被解析(resolve),所以会先去解析一下。通过其操作数所记录的常量池下标0x0002,找到常量池项#2,发现该常量池项也尚未被解析(resolve),于是进一步去解析一下。通过Methodref所记录的class_index找到类名,进一步找到被调用方法的类的ClassClass结构体;然后通过name_and_type_index找到方法名和方法描述符,到ClassClass结构体上记录的方法列表里找到匹配的那个methodblock;最终把找到的methodblock的指针写回到常量池项#2里。
也就是说,原本常量池项#2在类加载后的运行时常量池里的内容跟Class文件里的一致,是:

[00 03] [00 11]

【刚加载进来的时候数据仍然是按高位在前字节序存储的 】
而在解析后,假设找到的methodblock*是0x45762300,那么常量池项#2的内容会变为:

[00 23 76 45]

(解析后字节序使用x86原生使用的低位在前字节序(little-endian),为了后续使用方便)这样,以后再查询到常量池项#2时,里面就不再是一个符号引用,而是一个能直接找到Java方法元数据的methodblock了。这里的methodblock就是一个“直接引用”。
回顾一下,在解析前那条指令的内容是:

[B6] [00 02]

而在解析后,这块代码被改写为:

[D6] [06] [01]

B6:invokevirtual
D6:invokevirtual_quick,原本存储操作数的2字节空间现在分别存了2个1字节信息,第一个是虚方法表的下标(vtable index),第二个是方法的参数个数。这两项信息都由前面解析常量池项#2得到的methodblock*读取而来。
也就是:

invokevirtual_quick vtable_index=6, args_size=1

这里例子里,类X对应在JVM里的虚方法表会是这个样子的:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: X.bar:()V

所以会直接调用虚方法表里面的方法所在的内存地址

初始化

类初始化是类加载的最后一步,前面类加载的过程中,除了加载阶段用户可以通过自定义ClassLoader参与,其他的都不可以参与,到了初始化阶段,才开始真正的执行Java代码。

类构造器代码是哪一块?我们最熟悉的应该就是Java中的static代码块

/**
 * Created by huangwt on 2019/3/11.
 */
public class X {
    static {
        a = 3;
    }
    static int a = 1;
}

但是对于虚拟机来说,类的构造方法是,这个函数是由虚拟机将Java文件中所有静类变量的复制和static语句合并产生的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量

/**
 * Created by huangwt on 2019/3/11.
 */
public class X {

    static int a = 1;

    static {
        System.out.println(a);
        //合法,但是放到定义之前就不合法
    }
}

Clinit和init不一样,他不会显示的调用父类的构造函数,虚拟机会保证子类初始化之前,父类都初始化完成,所以第一个被执行的就是Object的Clinit方法。
Clinit方法对于类或者接口来说不是必须的,如果没有静态语句块或者没有对变量的赋值操作,就不会生成clinit代码块。
虚拟机会保证一个类的init方法在多线程下被同步,如果多个线程初始化同一个类,则会发生阻塞,所以要避免在clinit代码块中执行耗时的操作

类加载器

篇幅的问题这里我只拿Android的类加载器来举例和说明。

BootClassLoader

和常规JVM一样,加载系统类用的还是BootClassLoader,BootClassLoader实例在Android系统启动的时候被创建,用于加载一些Android系统框架的类,其中就包括APP用到的一些系统类。

PathClassLoader

在应用启动的时候创建PathClassLoader实例,只能加载系统中已经安装过的apk;

Provides a simple ClassLoader
implementation that operates on a list of files and directories in the local file system, but does not attempt to load classes from the network. Android uses this class for its system class loader and for its application class loader(s).

DexClassLoader

可以加载jar/apk/dex,可以从SD卡中加载未安装的apk;

A class loader that loads classes from .jar and .apk files containing a classes.dex entry. This can be used to execute code not installed as part of an application.
This class loader requires an application-private, writable directory to cache optimized classes. Use Context.getDir(String, int) to create such a directory:

pathClassLoader和DexClassLoader的父类都是BaseDexClassLoader,在BaseClassLoader类中有一个很重要的属性dexpathlist,和类的加载关联很大,下面看一下类的具体加载逻辑:

protected Class loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

加载过程:

1)会先查找当前ClassLoader是否加载过此类,有就返回;
2)如果没有,查询父ClassLoader是否已经加载过此类,如果已经加载过,就直接返回Parent加载的类;
3)如果整个类加载器体系上的ClassLoader都没有加载过,才由当前ClassLoader加载,整个过程类似循环链表一样。

klassOop SystemDictionary::find_class(int index, unsigned int hash,
                                      Symbol* class_name,
                                      Handle class_loader) {
  assert_locked_or_safepoint(SystemDictionary_lock);
  assert (index == dictionary()->index_for(class_name, class_loader),
          "incorrect index?");

  klassOop k = dictionary()->find_class(index, hash, class_name, class_loader);
  return k;
}

Java的类加载机制和此基本类似,都是责任链设计模式的双亲委派机制,那么双亲委派的作用有哪些呢?

1)共享功能,一些Framework层级的类一旦被顶层的ClassLoader加载过就缓存在内存里面,以后任何地方用到都不需要重新加载。
2)隔离功能,保证java/Android核心类库的纯净和安全,防止恶意加载。

简单的热修复

这里提的热修复是基于ClassLoader实现的基础demo,在不进行干涉的情况下, 上下文所使用的类加载器都是同一个,也就是PathClassLoader,那么想要基于类加载器实现简单的热修复,也就是替换类,有两种方案。

1)破坏双亲委任机制
我们加载类A的顺序是先由parent类加载器进行加载,加载失败才会由子加载器加载,可以选择自定义类加载器替换上下文加载器重写加载类的方法和顺序
2)加载补丁包插入系统默认的ClassLoader

private final DexPathList pathList;
#Note that all the *.jar and *.apk files from {@code dexPath} might be
#first extracted in-memory before the code is loaded. This can be avoided
#by passing raw dex files (*.dex) in the {@code dexPath}.

这个DexPathList可以理解为一个数组对象,持有了一个
image.png
image.png

比较核心的动态加载dex的方法

    private static void loadDex(Context context) {

        File fileDir = context.getDir(DEX_DIR, Context.MODE_PRIVATE);
        pathClassLoader = context.getClassLoader();

        String patchDir = fileDir.getAbsolutePath() + File.separator + "qsxtpatch_dex";
        File patchFile = new File(patchDir);
        if (!patchFile.exists()) {
            patchFile.mkdir();
        }

        List patchFiles = loadPatchFile(context);

        for (File dex : patchFiles) {
            DexClassLoader dexClassLoader = new DexClassLoader(dex.getAbsolutePath(), patchFile.getAbsolutePath(), null, pathClassLoader);
            try {
                Object dexPathList = getDexPathList(pathClassLoader);
                //获取到classLoader的dexpathlist
                Object dexElements = getDexElements(dexPathList);
                //获取刀dexpathlist的elements[]数组,代表着Dex文件的elements
                Object dexPathListDexFile = getDexPathList(dexClassLoader);
                //获取新建DexClassLoader的DexpathList字段
                Object dexElementsDexFile = getDexElements(dexPathListDexFile);
                //获取新建DexClassLoader的elements数组
                Object newDexFile = mergeArray(dexElementsDexFile, dexElements);
                //将新旧的合并,并且以补丁包的dex文件放置于数组的前列
                putFieldObj("dexElements", newDexFile, dexPathList);
                //将pathClassloader的dexElements替换为修复好的Dex集合
                logFix(dex.toString() + "Fix Success");

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

你可能感兴趣的:(JVM阅读笔记-初学)