E.JVM

作者: 学海一乌鸦 | 来源:发表于2020-07-12 22:19 被阅读0次

    1、JVM定义

    Java虚拟机包括程序计数器(线程私有),虚拟机栈(线程私有),本地方法栈(线程私有),Java堆(线程共享),方法区(线程共享)。 JVM屏蔽了与具体操作系统平台相关的信息,在执行字节码时,实际上最终是把字节码解释成具体平台上的机器指令执行

    image.png

    2、JVM运行时数据区

    大多数 JVM 将内存区域划分为 程序计数器 , 虚拟机栈 , 本地方法栈方法区 , 。其中方法区和堆 是线程共享的程序计数器,虚拟机栈,本地方法栈非线程共享的。为什么分为 线程共享和非线程共享的呢?

    概括地说来,JVM初始运行的时候都会分配好 Method Area(方法区)Heap(堆) ,而JVM 每遇到一个线程,就为其分配一个 Program Counter Register(程序计数器) , VM Stack(虚拟机栈)和Native Method Stack (本地方法栈), 当线程终止时,三者(虚拟机栈,本地方法栈和程序计数器)所占用的内存空间也会被释放掉。这也是为什么把内存区域分为线程共享和非线程共享的原因,非线程共享的那三个区域的生命周期与所属线程相同,而线程共享的区域与JAVA程序运行的生命周期相同,所以这也是系统垃圾回收的场所只发生在线程共享的区域(实际上对大部分虚拟机来说只发生在Heap上)的原因。

    程序计数器

    如果方法不是native的,pc寄存器包含当前正在被执行的jvm指令地址,如果方法是native的,pc寄存器的值是未定义的。
    程序计数器的主要作用是记录线程运行时的状态,方便线程被唤醒时能从上一次被挂起时的状态继续执行,需要注意的是,程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域

    JVM栈

    在函数中定义的一些基本类型的变量对象的引用变量方法局部变量方法的返回地址都是在函数的栈内存中分配,当超过变量的作用域后,Java 会自动释放掉为该变量分配的内存空间,该内存空间可以立即被另作它用。

    栈具有数据结构中栈的特点,后进先出,所有存放在它里面的数据都是生命周期很明确(当然要求它不能存放太久,占有的空间确定而且占用空间小),能够快速反应的!所有在Java中它存放的是8个基本数据类型[整型 byte short int long\浮点型 float double\逻辑型 boolean\字符型 char]和引用变量的,用完就马上销毁

    优点:存取速度比堆要快,仅次于寄存器。

    缺点:存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。

    所有对象都在这里分配内存,是垃圾收集的主要区域("GC 堆")。
    堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。

    它是JVM用来存储对象实例以及数组值的区域,Java中所有通过new创建的对象和定义的数组的内存都在此分配,还包括类的成员变量,Heap中的对象的内存需要等待GC进行回收。

    (1) 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的

    (2) Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配

    (3) TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

    优点:堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。

    缺点是:由于要在运行时动态分配内存,存取速度较慢。

    方法区域(静态存储区)

    • 用于存放已被加载的类信息、常量、静态变量。
    • 和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
    • 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
    String s = "hello";
    String t = "hello";
    System.out.println(s==t);// true  s和t指向内存常量区的同一个字符串 ;
    
     class A
    {
      private String a = "aa"; // a 为成员变量的引用,在堆区,“aa”为未经 new 的常量,在常量区
      public boolean methodB() {
        String  b = "bb"; // b 为局部变量的引用,在栈区,“bb”为未经 new 的常量,在常量区
        final String c ="cc"; // c 为局部变量的引用,在栈区,“cc”为未经 new 的常量,在常量区
       }
    }
    

    本地方法堆栈(Native Method Stacks)

    本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。

    本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

    本地内存

    线程共享区域,Java 8 中,本地内存,也是我们通常说的堆外内存,包含元空间和直接内存,注意到上图中 在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能,也就不存在由于永久代限制大小而导致的 OOM 异常。

    2.1对象的创建方法

    • 使用new关键字-->调用了构造函数
    • 使用class类的newInstance()方法-->调用了构造函数class.forName().newInstance()
    • 使用反射的Constructor类的newinstance()方法-->调用了构造函数
      Constructor<Person> constructor = Person.class.getConstructor(int.class);
      Person person = constructor.newInstance(23);
    • 使用clone方法
    • 使用反序列化,类需要实现serializable接口

    2.2 对象的内存分配

    Object obj = new Object();

    obj 保存在java栈中的局部变量表里,作为一个引用数据出现。 New Object()会在java堆上分配一块存储Object类型实例的所有数值的结构化内存。

    2.3 对象的访问定位

    对象访问方式有两种:句柄和直接指针:

    句柄:在java堆中会划分出一块内存作为句柄池,reference中存储的对象是句柄地址。而句柄中包含对象实例数据和类型数据各自的具体地址信息。最大的好处是如果对象地址发生变化不需要改变reference的值,只需要改变句柄中实例数据指针。

    直接指针访问:reference直接存储对象的地址,最大的好处是速度更快

    小结

    • JVM运行时内存 = 共享内存区 + 线程内存区

    • 共享内存区=堆+持久带

    • 持久带实现了方法区

    • 堆=新生代+年老代(新生代占堆空间的1/3;年老代占堆空间的2/3)

      • 堆分为Old Space和Young Space。

      • Old Space主要存放应用程序中生命周期长的存活对象;

      • 新生代=eden+S0+S1,(空间为8:1:1)Eden(伊甸园)主要存放新生的对象;S0和S1是两个大小相同的内存区域,主要存放每次垃圾回收后Eden存活的对象,作为对象从Eden过渡到Old Space的缓冲地带(S是指英文单词Survivor Space)。堆之所以要划分区间,是为了方便对象创建和垃圾回收

    • 线程内存区=单个线程内存+单个线程内存+.......

      单个线程内存=PC Regster+JVM栈+本地方法栈

      JVM栈=栈帧+栈帧+.....

      一个栈帧代表一个方法调用。

    3、JVM垃圾回收

    垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。

    GC (Garbage Collection)的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停

    3.1 判断一个对象是否死亡

    引用计数算法

    给对象添加一个引用计数器,每当一个地方引用,计数器的值加1;当引用失效时,计数器的值减1;任何技术器为0的对象就是不可能再被使用的对象。

    //在上述代码中,a 与 b 引用的对象实例互相持有了对象的引用,
    //因此当我们把对 a 对象与 b 对象的引用去除之后
    //由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。
    public class Test {
    
        public Object instance = null;
    
        public static void main(String[] args) {
            Test a = new Test();
            Test b = new Test();
            a.instance = b;
            b.instance = a;
            a = null;
            b = null;
            doSomething();
        }
    }
    
    
    image.png

    实现简单,判定效率高,但是很难解决对象之间的相互循环引用的问题。

    可达性分析算法

    现代虚拟机基本都是采用这种算法来判断对象是否存活,可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。。。(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。


    image.png

    如图示,如果用可达性算法即可解决上述循环引用的问题,因为从GC Root 出发没有到达 a,b,所以 a,b 可回收
    a, b 对象可回收,就一定会被回收吗?并不是,对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!
    注意: finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!

    GC用的引用可达性分析算法中,哪些对象可作为GC Roots对象?
    ​ • Java虚拟机栈中引用的对象
    ​ • 方法区中的静态成员引用的对象
    ​ • 方法区中的常量引用对象
    ​ • 本地方法区中的JNI(Native方法)引用对象。

    3.2 引用类型

    无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
    Java 提供了四种强度不同的引用类型。

    (1)强引用:广泛存在的,类似"Object obj=new Object()",只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。

    (2)软引用:描述一些有用,但非必需的对象,软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)
    使用 SoftReference 类来创建软引用。

    Object obj = new Object();
    SoftReference<Object> sf = new SoftReference<Object>(obj);
    obj = null;  // 使对象只被软引用关联
    

    (3)弱引用:用来描述非必需对象的,在GC时一定会被GC回收,只能存活到下一次垃圾收集发生之前。
    使用 WeakReference 类来创建弱引用。

    Object obj = new Object();
    WeakReference<Object> wf = new WeakReference<Object>(obj);
    obj = null;
    

    (4)虚引用:又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
    为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
    使用 PhantomReference 来创建虚引用。

    Object obj = new Object();
    PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
    obj = null;
    

    3.3垃圾收集算法

    标记-清除算法

    image.png
    • 在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。

    • 在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。

    • 在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。

      问题:效率不高,标记和清除两个过程效率都不高;空间问题,标记清除之后回产生大量不连续的内存碎片,导致无法给大对象分配内存。

    复制算法 【新生代使用】

    image.png

    ○ 内存按容量分成大小相等的两块,每次只是用其中的一块,这一块用完了,就将还存活的对象复制到另外一块上面,然后把已使用的内存空间一次清理掉。

    实现简单,运行高效;代价就是内存利用率不高。

    此为新生代最常用的算法

    现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

    HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

    标记-整理【老年代使用

    image.png

    ○ 标记阶段:标记出所有需要回收的对象
    ○ 整理阶段:所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
    优点:
    不会产生内存碎片
    不足:
    需要移动大量对象,处理效率比较低。

    三者比较

    • 效率:复制算法 > 标记/整理算法 > 标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如
      此)。
      ○ 内存整齐度:复制算法=标记/整理算法>标记/清除算法。
      ○ 内存利用率:标记/整理算法=标记/清除算法>复制算法

    分代收集

    现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

    一般将堆分为新生代和老年代。

    • 新生代使用:复制算法
    • 老年代使用:标记 - 清除 或者 标记 - 整理 算法

    3.4 各个垃圾收集器是怎么工作的

    image.png

    以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
    单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
    串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMSG1 之外,其它垃圾收集器都是以串行的方式执行。

    Serial(串行+单线程+Client)

    image.png
    • Serial 翻译为串行,也就是说它以串行的方式执行。
    • 它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
    • 它的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
    • 它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。

    ParNew(串行+多线程+Server)

    image.png

    ParNew: 是Serial收集器的多线程版本,垃圾回收时采用多线程方式进行回收。默认情况下使用的线程数是cpu数量。除了serial收集器,目前只有它能和CMS收集器配合工作。

    ParNew 主要工作在 Server 模式,我们知道服务端如果接收的请求多了,响应时间就很重要了,多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,所以是许多运行在 Server 模式下的虚拟机的首选新生代收集器,另一个与性能无关的原因是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作,CMS 是一个划时代的垃圾收集器,是真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程(基本上)同时工作。

    在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。

    Parallel Scavenge(多线程+串行+吞吐量)

    Parallel Scavenge: 使用复制算法收集器,也是一个串行的多线程收集器。
    Parallel Scavenge收集器与其他收集器关注点不同,其它收集器主要关注缩短垃圾回收时用户线程的停顿时间。而它关心吞吐量,即运行用户代码时间/(运行用户代码时间+垃圾收集时间)。停顿时间越短越适合需要与用户交互的程序,高吞吐量则可以最高效率的利用CPU时间。

    Serial/ParNew/Parallel Scavenge 新生代

    Serial Old(单线程+串行+Client)

    image.png

    Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用,如果在 Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用

    Parallel Old(配合Parallel Scavenge+注重吞吐量)

    image.png

    是 Parallel Scavenge 收集器的老年代版本。
    在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

    CMS(并行)

    image.png

    CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

    分为以下四个流程:

    初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
    并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
    重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
    并发清除:不需要停顿。
    在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

    具有以下缺点:

    • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。

    • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

    • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

      Serial Old/Parallel Old/CMS年老代

      G1 收集器

    G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

    与 CMS 相比,它在以下两个方面表现更出色
    运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
    在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。

    堆被分为新生代老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

    image.png
    G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。 image.png

    通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

    image.png

    G1 收集器的运作大致可划分为以下几个步骤:

    • 初始标记

    • 并发标记

    • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。

    • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
      具备如下特点:

    • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。

    • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

    4.内存泄漏和内存溢出

    4.1基本概念

    内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;

    内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

    如果内存泄露,我们要找出泄露的对象是怎么被GC ROOT引用起来,然后通过引用链来具体分析泄露的原因。

    memory leak会最终会导致out of memory!

    4.2内存泄漏的原因

    • 静态集合类引起的内存泄漏

    静态集合类,使用Set、Vector、HashMap等集合类的时候需要特别注意。当这些类被定义成静态的时候,由于他们的生命周期跟应用程序一样长,这时候就有可能发生内存泄漏。

    Static Vector v = new Vector(10);
    for (int i = 1; i<100; i++)
    {
    Object o = new Object();
    v.add(o);
    o = null;
    }//
    

    在上面的代码中,循环申请了Object对象,并添加到Vector中,然后设置为null,可是这些对象被vector引用着,因此不能被GC回收,因此造成内存泄漏。因此要释放这些对象,还需要将它们从vector删除,最简单的方法就是将vector设置为null。

    • 当集合里面的对象属性被修改后,再调用remove()方法时不起作用
    public static void main(String[] args) {
            Set<Person> set = new HashSet<Person>();
            Person p1 = new Person("唐僧", "pwd1", 25);
            Person p2 = new Person("孙悟空", "pwd2", 26);
            Person p3 = new Person("猪八戒", "pwd3", 27);
            set.add(p1);
            set.add(p2);
            set.add(p3);
            System.out.println("总共有:" + set.size() + " 个元素!"); // 结果:总共有:3 个元素!
            p3.setAge(2); // 修改p3的年龄,此时p3元素对应的hashcode值发生改变
            set.remove(p3); // 此时remove不掉,造成内存泄漏
            set.add(p3); // 重新添加,居然添加成功
            System.out.println("总共有:" + set.size() + " 个元素!"); // 结果:总共有:4 个元素!
            for (Person person : set) {
                System.out.println(person);
            }
        }
    
    • 监听器:

      在Java编程中,我们都需要和监听器打交道,通常一个应用中会用到很多监听器,我们会调用一个控件,诸如addXXXListener()等方法来增加监听器,但往往在释放的时候却没有去删除这些监听器,从而增加了内存泄漏的机会。

    • 各种连接

      比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。对于Resultset 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,因为Connection 在任何时候都无法自动回收,而Connection一旦回收,Resultset 和Statement 对象就会立即为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。

    • 单例模式

      单例模式:因为单例对象初始化后将在JVM的整个生命周期内存在,如果它持有一个外部对象的(生命周期比较短)引用,那么这个外部对象就不能被回收,从而导致内存泄漏。如果这个外部对象还持有其他对象的引用,那么内存泄漏更严重。

    5.JVM堆内存设置

    image.png

    由图可知,大部分的对象都很短命,都在很短的时间内都被回收了(IBM 专业研究表明,一般来说,98% 的对象都是朝生夕死的,经过一次 Minor GC 后就会被回收),所以分代收集算法根据对象存活周期的不同将堆分成新生代和老生代(Java8以前还有个永久代),默认比例为 1 : 2,新生代又分为 Eden 区, from Survivor 区(简称S0),to Survivor 区(简称 S1),三者的比例为 8: 1 : 1,这样就可以根据新老生代的特点选择最合适的垃圾回收算法,我们把新生代发生的 GC 称为 Young GC(也叫 Minor GC),老年代发生的 GC 称为 Old GC(也称为 Full GC)。

    5.1 Minor GC 和 Full GC

    • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。

    • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

    5.2原理

    JVM共享内存分为2块:Permanent Space 和 Heap Space。

    • Permanent 即 持久代(Permanent Generation),主要存放的是Java类定义信息,与垃圾收集器要收集的Java对象关系不大。
    • Heap = { Old + NEW = {Eden, from, to} },Old 即 年老代(Old Generation),New 即 年轻代(Young Generation)。年老代和年轻代的划分对垃圾收集影响比较大。

    5.3内存分配策略

    a.对象优先在 Eden 分配

    大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

    1. 大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1, Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把 Eden 区对象全部清理以释放出空间。
    2. 当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。
    3. 若再触发下一次 Minor GC,则重复上一步,只不过此时变成了 从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收, S0, S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是复制算法,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥 Eden:S0:S1 默认为 8:1:1 的原因),S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。

    b.大对象直接进入老年代

    大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

    经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

    -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

    c.长期存活的对象进入老年代

    为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

    -XX:MaxTenuringThreshold 用来定义年龄的阈值。

    d.动态对象年龄判定

    虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

    e.空间分配担保

    在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

    如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

    f. Stop The World

    如果老年代满了,会触发 Full GC, Full GC 会同时回收新生代和老年代(即对整个堆进行GC),它会导致 Stop The World(简称 STW),造成挺大的性能开销。

    什么是 STW ?所谓的 STW, 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。


    image.png

    一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理整个堆中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)。

    现在我们应该明白把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。想想新生代如果只设置 Eden 会发生什么,后果就是每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。

    由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:

    • 循环的末尾
    • 方法返回前
    • 调用方法的 call 之后
    • 抛出异常的位置
      小结:

    什么时候对象会进入到老年代中?

    • YGC时,To Survicor 区不足以存放存活的对象,对象会进入老年代中;
    • 经过多次YGC后,如果存活的对象达到了设定阈值,则会晋升到老年代中;
    • 动态年龄判断:to survivor中相同年龄的对象,如果其大小之和占到了To Survivor区一半以上的空间,那么大于此年龄的对象会直接进入老年代,而不需要达到默认的分代年龄;
    • 大对象:对象大于一定的值,则会绕过新生代,直接在老年代中分配;

    什么时候触发FULL GC?

    • 当晋升到老年代的对象大于了老年代的剩余空间时,就会触发FGC;
    • 老年代的内存使用率达到了一定阈值,直接触发full gc。
    • 空间分配担保:在YGC之前,会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果小于,说明YGC可能是不安全的,则会查看参数HandlePromotionFailure是否被设置成了运行担保失败,如果不允许则触发FULLGC;如果允许,则进一步的检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于也会触发FULL GC。

    5.7内存申请过程

    当一组对象生成时,内存申请过程如下:

    1. JVM会试图为相关Java对象在年轻代的Eden区中初始化一块内存区域。
    2. 当Eden区空间足够时,内存申请结束。否则执行下一步。
    3. JVM试图释放在Eden区中所有不活跃的对象(Young GC)。释放后若Eden空间仍然不足以放入新对象,JVM则试图将部分Eden区中活跃对象放入Survivor区。
    4. Survivor区被用来作为Eden区及年老代的中间交换区域。当年老代空间足够时,Survivor区中存活了一定次数的对象会被移到年老代。
    5. 当年老代空间不够时,JVM会在年老代进行完全的垃圾回收(Full GC)。
    6. Full GC后,若Survivor区及年老代仍然无法存放从Eden区复制过来的对象,则会导致JVM无法在Eden区为新生成的对象申请内存,即出现“Out of Memory”。

    5.8Minor GC ,Full GC 触发条件

    Minor GC触发条件:当Eden区满时,触发Minor GC。

    Full GC触发条件:

    (1)调用System.gc时,系统建议执行Full GC,但是不必然执行

    (2)老年代空间不足

    (3)方法区空间不足

    (4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

    (5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

    5.9 OOM异常原因

    内存溢出的原因

    • 堆溢出(Out of Memory Error:java heap space)

      • Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么这些对象数量到达最大堆的容量限制后就会产生内存溢出异常。
      • 首先确认内存中的对象是否是必要的,也就是先分清楚内存泄漏还是内存溢出。使用内存映像分析工具
      • 内存泄漏:我们要找出泄露的对象是怎么被GC ROOT引用起来,然后通过引用链来具体分析泄露的原因
      • 内存溢出:程序本生需要的内存大于了我们给虚拟机配置的内存,这种情况下,我们可以采用调大
      • 具体原因:循环上万次的字符串处理创建上千万个对象在一段代码内申请上百M甚至上G的内存
    • 栈溢出(Stack Over Flow Error)
      当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
      栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。
      比如递归调用的过深,大量循环或死循环

    • 持久带溢出(Out of Memory Error:PermGen space)

      • 通常由于持久代设置过小,动态加载了大量Java类而导致溢出,解决办法唯有将参数 -XX:MaxPermSize 调大(一般256m能满足绝大多数应用程序需求)。
      • 运行时常量池溢出,也有可能是方法区中保存的class对象没有被及时回收掉或者class信息占用的内存超过了我们配置。
    • Out of Memory Error:unable to creat native thread

      • 无法创建本地线程:总容量不变,堆内存,非堆内存设置过大,会导致能给线程的内存不足。···

    6.类加载过程

    类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。


    image.png

    6.1双亲委派模型

    • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。

    6.2类加载的五个过程:加载、验证、准备、解析、初始化。

    加载是类加载的一个阶段,注意不要混淆。
    加载过程完成以下三件事:

    • 通过类的完全限定名称获取定义该类的二进制字节流。
    • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
    • 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

    验证
    主要时为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全.包含四个阶段的验证过程:
    1)文件格式验证:保证输入的字节流能够正确地解析并存储在方法区之内,格式上符合描述一个java类型信息的要求
    2)元数据验证:字节码语义信息的验证,以保证描述的信息符合java语言规范.验证点有:这个类是否有父类等.
    3)字节码验证:主要是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为.
    4)符号引用验证:对符号引用转化为直接引用过程的验证.

    准备:
    类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。

    实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

    初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
    public static int value = 123;
    如果类变量是常量,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是 0。
    public static final int value = 123;

    解析:

    将常量池的符号引用替换为直接引用的过程。
    其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

    初始化:
    执行静态变量的赋值操作以及静态代码块,完成初识化.初始化过程保证了父类中定义的初始化优先于子类的初始化.但接口不需要执行父类的初始化.
    初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
    <clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:

    public class Test {
        static {
            i = 0;                // 给变量赋值可以正常编译通过
            System.out.print(i);  // 这句编译器会提示“非法向前引用”
        }
        static int i = 1;
    }
    

    由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。例如以下代码:

    static class Parent {
        public static int A = 1;
        static {
            A = 2;
        }
    }
    
    static class Sub extends Parent {
        public static int B = A;
    }
    
    public static void main(String[] args) {
         System.out.println(Sub.B);  // 2
    }
    

    接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。

    虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

    Student s = new Student();在内存中做了哪些事情?
    ​ • 加载Student.class文件进内存
    ​ • 在栈内存为s开辟空间
    ​ • 在堆内存为学生对象开辟空间
    ​ • 对学生对象的成员变量进行默认初始化
    ​ • 对学生对象的成员变量进行显示初始化(期间会调用父类的静态代码块)
    ​ • 通过构造方法对学生对象的成员变量赋值(父类的构造函数,先无参,再有参)
    ​ • 学生对象初始化完毕,把对象地址赋值给s变量

    6.3 类初始化时机

    6.3.1 主动引用

    虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随之发生):

    • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。

    • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

    • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

    • 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

    6.3.2 被动引用

    以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:

    • 通过子类引用父类的静态字段,不会导致子类初始化。
      System.out.println(SubClass.value); // value 字段在 SuperClass 中定义

    • 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。
      SuperClass[] sca = new SuperClass[10];

    • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
      System.out.println(ConstClass.HELLOWORLD);

    6.4类加载器分类

    两种不同的类加载器:

    • 启动类加载器,C++语言实现,是虚拟机的一部分
    • 所有的类加载器,由Java语言实现,独立于虚拟机外部,并且全部继承于抽象类:Java.lang.ClassLoder

    更细致的划分:

    两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

    这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

    • JDK中提供了三个ClassLoader,根据层级从高到低为:
      1. 启动类加载器Bootstrap ClassLoader,主要加载JVM自身工作需要的类。该类加载器使用C++语言实现,属于虚拟机自身的一部分.加载%JAVA_HOME%\lib类库到内存中
      2. 扩展类加载器Extension ClassLoader,主要加载%JAVA_HOME%\lib\ext目录下的库类。开发者可以直接使用。
      3. 应用程序类加载器Application ClassLoader,主要加载Classpath指定的库类,开发者可以直接使用这个类加载器,一般情况下这个就是程序默认的类加载器。
    • JVM加载类的实现方式,我们称为 双亲委托模型:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委托给自己的父加载器,每一层的类加载器都是如此,因此所有的类加载请求最终都应该传送到顶层的Bootstrap ClassLoader中,只有当父加载器反馈自己无法完成加载请求时,子加载器才会尝试自己加载。

    6.5 双亲委托模型

    双亲委托模型的重要用途是为了解决类载入过程中的安全性问题。

    假设有一个开发者自己编写了一个名为Java.lang.Object的类,想借此欺骗JVM。现在他要使用自定义ClassLoader来加载自己编写的java.lang.Object类。然而幸运的是,双亲委托模型不会让他成功。因为JVM会优先在Bootstrap ClassLoader的路径下找到java.lang.Object类,并载入它

    image.png

    工作过程

    • 一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。

    好处

    • 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。

    例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 并放到 ClassPath 中,程序可以编译通过。由于双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,这是因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。rt.jar 中的 Object 优先级更高,那么程序中所有的 Object 都是这个 Object。

    实现

    以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

    public abstract class ClassLoader {
        // The parent class loader for delegation
        private final ClassLoader parent;
    
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            return loadClass(name, false);
        }
    
        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;
            }
        }
    
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            throw new ClassNotFoundException(name);
        }
    }
    
    

    自定义类加载器实现

    以下代码中的 FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。

    java.lang.ClassLoader 的 loadClass() 实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写 findClass() 方法。

    public class FileSystemClassLoader extends ClassLoader {
    
        private String rootDir;
    
        public FileSystemClassLoader(String rootDir) {
            this.rootDir = rootDir;
        }
    
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            byte[] classData = getClassData(name);
            if (classData == null) {
                throw new ClassNotFoundException();
            } else {
                return defineClass(name, classData, 0, classData.length);
            }
        }
    
        private byte[] getClassData(String className) {
            String path = classNameToPath(className);
            try {
                InputStream ins = new FileInputStream(path);
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                int bufferSize = 4096;
                byte[] buffer = new byte[bufferSize];
                int bytesNumRead;
                while ((bytesNumRead = ins.read(buffer)) != -1) {
                    baos.write(buffer, 0, bytesNumRead);
                }
                return baos.toByteArray();
            } catch (IOException e) {
                e.printStackTrace();
            }
            return null;
        }
    
        private String classNameToPath(String className) {
            return rootDir + File.separatorChar
                    + className.replace('.', File.separatorChar) + ".class";
        }
    }
    
    

    参考文档

    相关文章

      网友评论

          本文标题:E.JVM

          本文链接:https://www.haomeiwen.com/subject/nbokqktx.html