Q&A:JVM


JVM学习要点

①内存结构

❶运行时数据区

🌟JVM的主要组成部分及作用

JVM主要由类加载系统执行引擎运行时数据区本地方法接口四部分组成。

  • 类加载系统:加载类文件到内存

  • 执行引擎:负责解释指令,交由操作系统执行

  • 本地方法接口:与其他语言交互时所使用的

  • 运行时数据区:JVM的内存区域

工作原理:首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,再通过执行引擎(Execution Engine)将字节码翻译成底层系统指令,交由 CPU 去执行,而这个过程中需要调用其他语言的本地方法接口(Native Interface)来实现整个程序的功能。

🌟说说JVM 是如何管理内存的吧?

JVM在执行Java程序的过程中会将分配得到的内存划分为5个不同的数据区域。

有些区域随着虚拟机启动和退出而创建和销毁。如:方法区、堆。

有些区域随着用户线程的启动和结束而建立和销毁。如:程序计数器、虚拟机栈、本地方法栈。

🌟说一下JVM内存模型吧

有哪些区?分别干什么的?每个区放什么

  1. 程序计数器

每个线程都有一个程序计数器,用于指示当前线程执行到了哪一行字节码。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。如果线程执行的是本地方法,则程序计数器值为空(Undefined)。是唯一一个不会出现OOM的内存区域(消耗内存小且固定)。

  1. 虚拟机栈

每个线程都有一个私有的虚拟机栈,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

  • 局部变量表:存放了编译期可知的各种基本数据类型(8种)、对象引用(reference)和returnAddress类型。

    • 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示
    • 其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个
  • 操作数栈:用于存储操作数和操作符,存放方法执行过程中产生的中间计算结果。计算过程中产生的临时变量也会放在操作数栈中。

  • 动态连接:用于将符号引用转换为调用方法的直接引用,动态连接的信息包括方法名、方法描述符、类名等。

    • 当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。
  • 方法出口:存储当前方法执行完后应当跳转到的位置。

  1. 本地方法栈

本地方法栈中存储的信息与虚拟机栈类似,但它用于执行本地方法,通常是与操作系统相关的方法,如文件操作、网络通信等。

存储对象实例,是垃圾收集器管理的主要区域。所有的对象实例都要在堆上分配内存。堆是被所有线程共享的。

堆中存储的信息主要是对象实例、 字符串常量池、静态变量、线程分配缓冲区

  • 对象实例:new 创建的对象,类初始化生成的对象,基本数据类型的数组也是对象实例(new 创建)
  • 字符串常量池: JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
  • 静态变量:静态变量是有 static 修饰的变量,JDK8 时从方法区迁移至堆中
  • 线程分配缓冲区 TLAB:线程私有但不影响堆的共性,可以提升对象分配的效率

由于现在垃圾收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,就可以根据各个区域的特点选择合适的垃圾收集算法。

在 JDK 7 及其之前,堆内存被通常分为下面三部分:

  • 新生代(Young Generation)

  • 老年代(Old Generation)

  • 永久代(Permanent Generation)

JDK 8 之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存

  1. 方法区

是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。Java 8 之前方法区叫永久代,Java 8之后方法区叫做元空间。

🌟JVM线程共享区域

线程私有:

  • 程序计数器(为了线程切换后能恢复到正确的执行位置
  • 虚拟机栈、本地方法栈(为了保证线程中的局部变量不被别的线程访问到

线程共享:堆、方法区

🌟说一下堆栈的区别

存放的是对象的实例 存放的是基本数据类型和对象的引用
线程共享 线程私有
性能较慢,运行时的单位 性能相对较快,存储的单位

🌟为什么使用栈空间会比使用堆空间快?

  • 申请速度快:栈是编译时分配空间,而堆是动态分配(运行时分配空间),所以栈的申请速度快

  • 存储寻址速度快:栈的物理地址空间是连续的,而堆未必,查找堆的链表也会耗费较多时间,所以存储寻址速度慢。

  • CPU硬件操作速度快:cpu有专门的寄存器(esp,ebp)来操作栈,堆是使用间接寻址的,所以栈快。

🌟什么情况下会发生栈溢出

  • 当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出StackOverFlowError异常。这种情况通常是因为方法递归没终止条件。
  • 新建线程的时候没有足够的内存去创建对应的虚拟机栈,虚拟机会抛出OutOfMemoryError异常。比如线程启动过多就会出现这种情况。

🌟内存泄漏和内存溢出是什么,如何避免

内存泄漏:指无用对象持续占有内存或内存得不到及时释放

内存溢出:指程序运行过程中无法申请到足够的内存导致的错误

常见的内存泄漏产生原因:

  • 静态集合类引起内存泄漏,因为静态集合的生命周期和JVM一致,所以静态集合引用的对象不能被释放
  • 单例模式导致内存泄漏,因为单例模式的静态特性,它的生命周期和JVM的生命周期一致,如果单例对象持有外部对象的引用,这个对象也不会被回收
  • 内部类的对象被长期持有,那么内部类对象所属的外部类对象也不能被收回
  • 数据库连接、网络连接等没有释放,例如在数据库连接后不再使用时,必须调用close方法释放与数据库的连接,否则会造成大量对象无法被回收进而造成内存泄漏
  • 改变哈希值,例如在一个对象存储到HashSet后,改变了对象中参与计算哈希值的字段,那么会导致对象的哈希值发生变化,和之前存入HashSet的哈希值不同,也就无法通过当前对象的引用在HashSet中找到这个对象,无法从HashSet中删除对象,造成内存泄漏,这也是为什么通常利用String类型的变量当作HashMap的key,因为String类型是不可变的

内存泄漏解决方案:

  • 写代码时尽量避免上述会造成内存泄漏的情况

常见的造成内存溢出的原因:

  • 内存加载的数据量太大,内存不够用了
  • 代码中存在死循环或循环产生大量对象
  • 启动参数内存值设置过小
  • 长期的内存泄漏也会导致内存溢出

内存溢出解决方案:

  • 修改JVM启动参数,增加内存
  • 使用内存查看工具动态查看内存使用情况
  • 对代码进行排查,重点排查有没有上述提到的造成常见内存溢出情景的代码

🌟栈相关问题

调整栈大小,就能保证不出现溢出么?

  • 不能保证不溢出

分配的栈内存越大越好么?

  • 不是,一定时间内降低了OOM概率,但是会挤占其它的线程空间,因为整个空间是有限的。

垃圾回收是否涉及到虚拟机栈?

  • 不会

方法中定义的局部变量是否线程安全?

  • 如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
运行时数据区 是否存在Error 是否存在GC
程序计数器
虚拟机栈 是(SOE)
本地方法栈
方法区 是(OOM)

❷Java对象

🌟Java 对象的创建过程

  1. 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存:在类加载检查后,就要为新生对象分配内存了,对象内存所需大小在类加载完成后便可以确定,内存分配方式根据Java堆中内存是否规整主要分为指针碰撞空闲列表两种。
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这也是为什么字段在Java代码中可以不赋值就能直接使用的原因。
  4. 设置对象头:初始化零值后,虚拟机需要将对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息都是存放在对象的对象头中。根据虚拟机当前的运行状态不同,如是否使用偏向锁等,对象头都会有不同的设置方式。
  5. 执行init方法:上述操作完成后,从虚拟机的角度看,一个新的对象已经产生了。但从Java程序的角度看,对象创建才刚刚开始,所有的字段都还为零,所以,一般执行完new指令后还会接着执行<init>方法(初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量),把对象按照程序员的意愿进行初始化(赋值),这样一个真正可用的对象才算生产出来。

🌟对象创建时内存是如何分配的

JVM 为对象分配内存的过程:首先计算对象占用空间大小,接着在堆中划分一块内存给新对象

  • 如果内存规整,使用指针碰撞(Bump The Pointer)。所有用过的内存在一边,空闲的内存在另外一边,中间有一个指针作为分界点的指示器,分配内存仅是把指针向空闲那边挪动一段与对象大小相等的距离

  • 如果内存不规整,虚拟机维护一个空闲列表(Free List)。已使用的内存和未使用的内存相互交错,列表上记录哪些内存块是可用的,分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容

注:选择哪种分配方式由Java堆内存是否规整决定,Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用SerialParNew等基于复制算法或标记整理算法的垃圾收集器时系统采用的是指针碰撞,在使用CMS等基于标记清除算法的收集器时,采用的是空闲列表

🌟Java对象内存分配流程

逃逸分析中若可进行栈上分配优化,会优先进行对象栈上直接分配内存;然后尝试 TLAB 分配,一旦对象在 TLAB 空间分配内存失败时,JVM 就会通过使用加锁机制确保数据操作的原子性,从而直接在堆中分配内存

**栈上分配 (Stack Allocations)**:如果一个对象不会逃逸到线程外,那么将会在栈上分配内存来创建这个对象,而不是 Java 堆上,此时对象所占用的内存空间就会随着栈帧的出栈而销毁,从而可以减轻垃圾回收的压力。

  • 栈上分配使用的是栈来进行对象内存的分配
  • TLAB 分配使用的是 Eden 区域进行内存分配,属于堆内存

🌟对象创建时如何处理并发安全问题

在Java对象创建过程的第二步(分配内存)的时候有一个很重要的问题,就是线程安全,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况

虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配【通过设置 -XX:+UseTLAB参数来设定】

🌟Java对象的内存布局

对象在JVM中是怎么存储的?

对象头信息里面有哪些东西?

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

  • 对象头包括两部分信息第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个数组,那在对象头中还有一块数据用于记录数组长度

  • 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

  • 对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

🌟Java对象的访问定位

Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:句柄直接指针

句柄

  • Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
  • 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

直接指针

  • reference 中存储的直接就是对象的地址。对象中包含对象类型数据的指针,通过这个指针可以访问对象类型数据。
  • 使用直接指针访问方式最大的好处是访问对象速度快,它节省了一次指针定位的时间开销,虚拟机HotSpot主要是使用直接指针来访问对象。

②垃圾回收

❶垃圾判断

🌟哪些对象可以作为 GC Roots

  • 1.虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 2.本地方法栈中引用的对象(Native 方法)
  • 3.方法区中类静态属性引用的对象
  • 4.方法区中常量引用的对象
  • 5.所有被同步锁持有的对象

🌟如何判断对象是否死亡

GC的两种判定方法?

判断对象是否存活有两种方法:引用计数法和可达性分析。

  1. 引用计数法

对每个对象添加一个整型的引用计数器属性,用于记录对象被引用的情况;每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;当计数器为 0 的对象就是不可用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

  1. 可达性分析算法

通过 GC Roots 对象作为起点,开始向下搜索,所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则说明此对象是不可用的,需要被回收。

🌟可达性分析是DFS还是BFS?

新生代深度优先,老年代广度优先

原因:深度优先搜索法占内存少但速度较慢(递归),广度优先搜索算法占内存多但速度较快。

🌟标记为垃圾的对象一定会被回收吗

不一定。要真正宣告一个对象死亡,至少要经历两次标记过程。  

第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;  

第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。第二次标记成功的对象将真的会被回收,如果对象在 finalize() 方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

🌟什么是浮动垃圾?

在并发回收时,应用运行的同时进行垃圾回收,所以有些垃圾可能在垃圾回收进行完成时产生,这样就造成了“Floating Garbage”,这些垃圾需要在下次垃圾回收周期时才能回收掉。所以,并发收集器一般需要20%的预留空间用于这些浮动垃圾。

🌟如何判断一个常量是废弃常量

运行时常量池主要回收的是废弃的常量。假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该常量,说明常量 “abc” 是废弃常量,如果这时发生内存回收的话而且有必要的话(内存不够用),”abc” 就会被系统清理出常量池

🌟如何判断一个类是无用的类

方法区主要回收的是无用的类,判定一个类是否是无用的类,需要同时满足下面 3 个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是可以,而并不是和对象一样不使用了就会必然被回收

🌟引用类型总结

强、软、弱、虚引用有什么区别?具体使用场景是什么?生命周期不同

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。

  1. 强引用:只要强引用关系还存在,垃圾回收器就永远不会回收掉被引用的对象。

  2. 软引用:如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象。软引用可用来实现内存敏感的高速缓存。

  3. 弱引用:在进行垃圾回收时,不管当前内存空间足够与否,都会回收只具有弱引用的对象。

  4. 虚引用:虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,任何时候都可能被垃圾回收。虚引用的唯一目的是在于跟踪垃圾回收过程,能在对象被回收时收到一个系统通知

🌟使用软引用能带来的好处

特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

❷垃圾回收策略

🌟垃圾回收策略的理解/触发条件?

Minor GC 和 Full GC 有什么不同呢?

什么情况下触发垃圾回收?

Full GC触发机制

Minor GC(新生代GC):指发生在新生代的垃圾收集动作,Java对象大多存活时间不长,所以Minor GC的发生会比较频繁,回收速度也比较快

Full GC/Major GC(老年代GC):指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Minor GC(不是必然的),Major GC的速度一般会比Minor GC慢10倍以上。

Minor GC 触发条件:当 Eden 空间满时,就将触发一次 Minor GC

Full GC 触发条件

  1. 调用System.gc()时,系统建议JVM执行Full GC,但不是必然执行的
  2. 老年代空间不足,创建的大对象的内存大于老年代空间,导致老年代空间不足,则会发生Full GC
  3. 空间分配担保失败:通过Minor GC后进入老年代的平均大小大于老年代的可用空间,会触发Full GC
  4. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
  5. JDK1.7及以前的永久代空间满了,在JDK1.7以前,HotSpot虚拟机的方法区是永久代实现,在永久代中会存放一些Class的信息、常量、静态变量等数据,在永久代满了,并且没有配置CMS GC的情况下就会触发Full GC,在JDK1.8开始移除永久代也是为了减少Full GC的频率

🌟为什么需要垃圾回收?

在Java中垃圾回收的目的是回收释放不再被引用的实例对象,这样做可以减少内存泄漏、内存溢出问题的出现

🌟内存分配和 GC 过程

JVM GC原理,JVM怎么回收内存

Java GC机制?

对象优先在 Eden 分配:

  • 当创建一个对象的时候,对象会被分配在新生代的 Eden 区,当 Eden 区没有足够空间进行分配时,触发 Minor GC
  • 当进行 Minor GC 时,将 Eden 区存活的对象复制到 Survivor 的 to 区,并且当前对象的年龄会加 1,清空 Eden 区,再将 from 区 和 to 区进行互换
  • 当再一次触发 Minor GC 的时候,会把 Eden 区和 Survivor 的 from 区中存活下来的对象,复制到 to 区中,这些对象的年龄会加 1,清空 Eden 区和 from 区,再将 from 区 和 to 区进行互换

晋升到老年代:

  • 大对象直接进入老年代:大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。为了避免在 Eden 和 Survivor 之间的大量复制而降低效率。

    -XX:PretenureSizeThreshold:大于此值的对象直接在老年代分配

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

    -XX:MaxTenuringThreshold:定义年龄的阈值,对象头中用 4 个 bit 存储,所以最大值是 15,默认也是 15

  • 动态对象年龄判定:Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所占用大小超过了 Survivor 空间的 50% 时,则大于等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

    -XX:TargetSurvivorRatio=percent :设定survivor区的目标使用率,默认值是 50%

    取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄的阈值

空间分配担保:

  • 空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
  • 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的
  • 如果不成立,虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure) ,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试着进行一次 Minor GC;如果小于或者 -XX: HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC

老年代垃圾回收。随着新生代对象的不断晋升,老年代的对象变得越来越多,达到容量阈值后老年代也会发生垃圾回收,我们称之为Major GC

🌟空间分配担保机制是什么?

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象空间,如果大于,直接进行Minor GC;如果不大于,则会查看是否允许担保失败,如果允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试进行一次Minor GC;如果小于,则进行一次Full GC。

🌟对象从加载到JVM再到被GC经历了什么

  1. 对象⾸先会分配在堆内存中新⽣代的Eden,然后经过⼀次Minor GC,对象如果存活,就会进⼊幸存区。在后续的每次GC中,如果对象⼀直存活,就会在幸存区来回拷⻉,每移动⼀次,年龄加1。超过⼀定年龄后,对象转⼊⽼年代。
  2. 当⽅法执⾏结束后,栈中的指针会先移除掉。
  3. 堆中的对象,经过Full GC,就会被标记为垃圾,然后被GC线程清理掉。

❸垃圾回收算法

🌟对象垃圾回收算法有哪些,优缺点

垃圾回收算法有四种,分别是标记清除法、标记整理法、标记复制算法、分代收集算法

标记清除算法—Mark-Sweep

该算法分为“标记”和“清除”阶段:首先利用可达性去遍历内存,标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。

优点:

  • 实现简单

  • 不移动对象

缺点:

  • 执行效率不稳定:如果 Java 堆上包含大量需要回收的对象,则需要进行大量标记和清除动作;
  • 内存空间碎片化:会产生大量不连续的空间,从而可能导致无法为大对象分配足够的连续内存。

标记整理法算法—Mark-Compact

标记阶段和标记清除算法一样,也是从根节点开始,将对象的引用进行标记,整理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题

优点:不会产生内存碎片

缺点:需要移动大量对象,处理效率比较低

复制算法—Copying

将原有的内存空间一分为二,每次只用其中的一块,当这一块的内存使用完了,将存活对象复制到另一个内存空间中,然后再把已经使用过的那块内存空间一次性清理掉。

优点:

  • 没有标记和清除过程,实现简单,运行速度快
  • 复制过去以后保证空间的连续性,不会出现碎片问题

缺点:

  • 浪费内存空间,内存空间变为了原有的一半
  • 如果内存中多数对象都是存活的,这种算法将产生大量的复制开销

分代收集算法

根据各个年代的特点采用最适当的收集算法。一般将堆分为新生代和老年代。

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

在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用标记复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用标记-清理或者标记-整理算法进行垃圾回收。

🌟垃圾回收算法区别

算法 速度 空间开销 移动对象
复制 最快 通常需要活对象的 2 倍大小(不堆积碎片)
标记清除 中等 少(但会堆积碎片)
标记整理 最慢 少(不堆积碎片)

❹垃圾回收器

目前在 Hotspot VM 中主要有分代收集和分区收集两大类,未来会逐渐向分区收集发展

🌟GC是什么?为什么要有GC?

Java语言没有提供释放已分配内存的显示操作方法,GC功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的。Java程序员不用担心内存管理,因为垃圾收集器会自动进行管理。

垃圾回收可以有效的防止内存泄露,内存溢出

🌟评判 GC 的两个核心指标

  • 延迟(Latency): 也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。
  • 吞吐量(Throughput): 程序有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受较长的停顿。

🌟为什么要STW

  • 如果不暂停用户线程,就意味着不断有垃圾的产生,永远也清理不干净;

  • 如果清理垃圾用的标记清除算法,用户线程的运行必然会导致对象的引用关系发生变化,即标记的变化,这样就会导致两种情况:漏标和错标。

    • 漏标:原来不是垃圾,但是在GC的过程中,用户线程将其引用关系修改,变成了null引用,成为了垃圾,这种情况还好,无非就是产生了一些浮动垃圾,下次GC再清理就好了;

      • 错标:一个对象,开始没有引用,但是GC的同时,用户线程又重新引用了它,但是这个时候,我们把它当作垃圾清理掉了,这将会导致程序运行错误。

🌟常见的垃圾回收器有哪些?

你知道哪几种垃圾回收器,各自的优缺点

垃圾回收器主要分为:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1、ZGC

垃圾收集器的特点:

收集器 运 行 作用位置 使用算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Serial Old 串行 老年代 标记整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
Parallel Old 并行 老年代 标记整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 both 复制算法/标记整理 响应速度优先 面向服务端应用,多处理器和大容量内存
ZGC 并发 复制算法 大内存低延迟服务的内存管理和回收
  • Serial

单线程收集器,使用一个垃圾收集线程去进行垃圾回收,在进行垃圾回收的时候必须暂停其他所有的工作线程( Stop The World ),直到它收集结束。

优点:简单高效;内存消耗小;没有线程交互的开销,单线程收集效率高;

缺点:需暂停所有的工作线程,用户体验不好。

  • ParNew

Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其他行为、参数与 Serial 收集器基本一致。

  • Parallel Scavenge

能够并行收集的多线程收集器,允许多个垃圾回收线程同时运行,降低垃圾收集时间,采用吞吐量优先。

Parallel Scavenge 收集器关注点是吞吐量【 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)】,高效率的利用 CPU 资源。CMS 垃圾收集器关注点更多的是用户线程的停顿时间。

  • Serial Old

Serial 收集器的老年代版本,单线程收集器,使用标记整理算法

Parallel Old

Parallel Scavenge 收集器的老年代版本。多线程垃圾收集,使用标记整理算法

  • CMS

Concurrent Mark Sweep ,并发标记清除,追求获取最短停顿时间,实现了让垃圾收集线程与用户线程基本上同时工作。分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站或者 B/S 系统的服务器端上,JDK9 被标记弃用,JDK14 被删除

  • G1

Garbage First,一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集低暂停时间的要求。追求高吞吐量和低停顿之间的最佳平衡

  • ZGC

它是一款低停顿高并发的收集器。ZGC几乎在所有地方并发执行,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上。适用于大内存低延迟服务的内存管理和回收

ZGC主要新增了两项技术,一个是着色指针Colored Pointer,另一个是读屏障Load Barrier。ZGC 是一个并发、基于区域(region)、增量式压缩的收集器。Stop-The-World 阶段只会在根对象扫描(root scanning)阶段发生,这样的话 GC 暂停时间并不会随着堆和存活对象的数量而增加。

🌟CMS收集器

CMS收集器与G1收集器的特点

CMS和G1了解么,CMS解决什么问题,说一下回收的过程。

Concurrent Mark Sweep(CMS),是一款并发的、使用标记-清除算法、响应时间优先针对老年代获取最短回收停顿时间为目标的垃圾回收器,其最大特点是让垃圾收集线程与用户线程同时工作

CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短(低延迟)越适合与用户交互的程序,良好的响应速度能提升用户体验

分为以下四个流程:

  • 初始标记:仅标记 GC Roots 能直接关联到的对象,速度很快,出现短暂STW
  • 并发标记:从 GC Roots 开始遍历整个对象图,在整个回收过程中耗时最长,不需要 STW,可以与用户线程并发运行
  • 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象,比初始标记时间长但远比并发标记时间短,需要 STW(不停顿就会一直变化,采用写屏障 + 增量更新来避免漏标情况)
  • 并发清除:清除标记为可以回收的对象,不需要移动存活对象,所以这个阶段可以与用户线程同时并发的

Mark Sweep 会造成内存碎片,不把算法换成 Mark Compact 的原因:Mark Compact 算法会整理内存,导致用户线程使用的对象的地址改变,影响用户线程继续执行

优点:并发收集、低延迟

缺点:

  • 吞吐量降低:在并发阶段虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,CPU 利用率不够高

  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure 导致另一次 Full GC 的产生

    浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,CMS 收集需要预留出一部分内存,不能等待老年代快满的时候再回收。如果老年区预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,导致很长的停顿时间

  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC;为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配

🌟CMS回收停顿了几次,为什么

两次,分别是初始标记阶段和重新标记阶段

  • 以最少的STW(暂停用户线程 - Stop The World)成本,找出要清理的垃圾。

  • 重新标记阶段是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录

清理的第一步,就是为了找出产生全量垃圾根对象,并打上标记为初始标记(耗时短,STW),同时把用户访问线程打开,并让后台线程去执行第二步并发标记,这些其实就是找出我们全量垃圾。

然后找出在我们执行并发标记这段时间由用户线程产生的增量垃圾进行重新标记(耗时短,STW),这个时候的GC标记,就是截止到当前时间,完整的垃圾信息,再执行并发清理。

🌟G1收集器

G1回收器讲下回收过程,

说下g1的应用场景,平时你是如何搭配使用垃圾回收器的

G1 (Garbage-First) ,是一款并发的、使用标记-整理/复制算法、响应时间优先针对新生代/老年代面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征

分为以下四个流程:

  • 初始标记(Initial Marking):仅标记 GC Roots 能直接关联到的对象,这个阶段是 STW 的,并且会触发一次 Minor GC

  • 并发标记 (Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB(Snapshot-at-the-beginning)记录下的在并发时有引用变动的对象。

  • 最终标记(Final Marking):为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,对用户线程做另一个短暂 STW,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录,但是可并行执行(防止漏标

  • 筛选回收(Live Data Counting and Evacuation):首先对 CSet 中各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,也需要 STW,由多条收集器线程并行完成的。

G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。

区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

G1 优点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU核心来缩短 STW 时间。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 缺点

  • 相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比 CMS 要高
  • 从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势,平衡点在 6-8GB 之间

🌟CMS收集器和G1收集器的区别

内存结构

  • CMS收集器是老年代的收集器,一般配合新生代的Serial和ParNew收集器一起使用;G1收集器收集范围是老年代和新生代,不需要结合其他收集器使用;
  • CMS收集器是一种以获取最短回收停顿时间为目标的收集器,G1收集器可预测垃圾回收的停顿时间。
  • CMS收集器是使用”标记-清除”算法进行的垃圾回收,容易产生内存碎片;而G1收集器使用的是”标记-整理”算法,进行了空间整合,降低了内存空间碎片。
  • CMS和G1的回收过程不一样。CMS是初始标记、并发标记、重新标记、并发清理;G1是初始标记、并发标记、最终标记、筛选回收。

🌟如何选择垃圾收集器?

1、单CPU或者小内存,单机程序: Serial ——最小化地使用内存和并行开销

2、多CPU,需要大吞吐量,如后台计算型应用:Parallel + ParallelOld——最大化应用程序的吞吐量

3、多CPU,追求低停顿时间,快速响应如互联网应用:ParNew + CMS——最小化 GC 的中断或停顿时

🌟ZGC 为什么零停机

ZGC 原理是什么,它为什么能做到低延时?

ZGC只有三个STW阶段:初始标记再标记初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。

即ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

新一代垃圾回收器ZGC的探索与实践 - 美团技术团队 (meituan.com)

🌟目前的JDK版本采用什么回收器?

  • JDK 8 默认垃圾收集器Parallel Scavenge(新生代)+ Parallel Old(老年代)

  • JDK 9 默认垃圾收集器G1

  • JDK 11 加入了实验性质的ZGC。它的回收耗时平均不到2毫秒。

  • JDK17 默认的垃圾回收器ZGC

❹分代思想

🌟永久代中会发生垃圾回收吗

在触发 Full GC 的情况下,永久代也会被进行垃圾回收

🌟Java8 的内存模型做了什么改动

Java8 开始,永久代就已经消失了,由元空间取而代之

🌟为什么使用元空间替换永久代

  • 为了解决永久代的OOM问题。永久代内存是有上限的(使用的是JVM的内存,与堆中的老年代是连续的),虽然可以通过参数来设置,但是JVM加载的类、方法的大小是很难确定的,所以很容易出现OOM问题。
  • 元空间是存储在本地内存里面,不再与堆连续,内存上限比较大,可以很好的避免OOM。
  • 永久代的对象是通过 Full GC 进行垃圾收集,也就是和老年代同时实现垃圾收集。
  • 替换成元空间以后,简化了Full GC。可以在不暂停的情况下并发地释放类数据,同时也提升了GC的性能
  • Oracle要合并Hotspot和JRockit的代码,而JRockit没有永久代。

🌟为什么有新生代和老年代/为什么分代

主要是因为对象大小不一样,对象生命周期不一样。分代为了使 JVM 能够更好的管理堆中的对象,包括内存的分配以及回收。从而提高垃圾回收效率。

  • 新生代(Young Gen):新生代主要存放新创建的对象,内存会相对比较小,垃圾回收会比较频繁。

  • 老年代(Tenured Gen):老年代主要存放JVM认为生命周期比较长的对象(经过几次 Minor GC 后仍然存活),内存相对会比较大,垃圾回收也相对没有那么频繁。

🌟新生代中为什么要分为Eden和survivor

  • Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生。
  • 如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发 Full GC,进行一次Full GC消耗的时间比Minor GC长得多,所以需要分为Eden和Survivor。

🌟为什么有两个survivor区

设置两个Survivor区最大的好处就是解决了内存碎片化,新生代垃圾回收采用了复制算法,这保证了to区中来自from区和Eden区的存活对象占用连续的内存空间,避免了碎片化的发生。

🌟Eden和survior的比例分配

  • Eden和survior的比例分配:【8:1:1】,新生代和老年代比例分配:【1:2】

新生代垃圾回收采用复制算法,需要将内存分为两块,基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分, HotSpot 虚拟机会将内存空间划分为一块较大的 Eden 和 两块较小的 Survivor 空间,它们之间的比例是 8:1:1 。 每次分配时只会使用 Eden 和其中的一块 Survivor ,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor 上,这样只有 10% 的内存空间会被浪费掉。当 Survivor 空间不足以容纳一次 Minor GC 时,此时由其他内存区域(通常是老年代)来进行分配担保。

🌟什么时候对象会进入老年代

  • 大对象直接进入老年代:大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。为了避免在 Eden 和 Survivor 之间的大量复制而降低效率。

  • 长期存活的对象进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄(默认15)则移动到老年代中

  • 动态对象年龄判定:Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所占用大小超过了 Survivor 空间的 50% 时,则大于等于该年龄的对象就可以直接进入老年代,无须等到阈值中要求的年龄。

③类加载

❶类加载过程

🌟类加载的时机

哪些情况会触发类的加载?

主动引用:对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化

  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条直接码指令时
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

被动引用:所有引用类的方式都不会触发初始化,称为被动引用

  • 通过子类引用父类的静态字段,不会导致子类初始化,只会触发父类的初始化
  • 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法
  • 常量(final 修饰)在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
  • 调用 ClassLoader 类的 loadClass() 方法加载一个类,并不是对类的主动使用,不会导致类的初始化

🌟什么是类加载?类加载的过程?

JVM中类加载机制,类加载过程?

描述一下JVM加载class文件的原理机制?

类加载指的是将类的class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个此类的对象,通过这个对象可以访问到方法区对应的类信息。

  1. 加载 :查找和导入class文件;

    • 通过类的全限定名获取定义此类的二进制字节流

    • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构

    • 在内存中生成一个代表该类的Class对象,作为方法区类信息的访问入口

  2. 链接

    • ❶验证:检查载入的class文件数据的正确性;

      • 主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证
    • ❷准备:为类变量分配内存并设置初始值的阶段;

    • ❸解析:虚拟机将常量池内的符号引用替换为直接引用的过程。

      • 符号引用用于描述目标,直接引用直接指向目标的地址。
  3. 初始化

    • 开始执行类中定义的Java代码,初始化阶段就是执行类构造器的 <clinit>() 方法的过程
      • 初始化静态变量,静态代码块。

🌟类的实例化顺序?

  1. 父类中的static代码块
  2. 当前类的static代码块
  3. 父类的普通代码块
  4. 父类的构造函数
  5. 当前类普通代码块
  6. 当前类的构造函数

❷类加载器

🌟类加载器有哪些?都加载哪些文件

实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器

主要有以下四种 ClassLoader,除启动类加载器外其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader

  • 启动类加载器:用来加载 Java 核心类库,由 C++实现,无法被 Java 程序直接引用。
    • 即加载 %JAVA_HOME%/lib目录下的 jar 包和类或者被 -Xbootclasspath参数指定的路径中的所有类
  • 扩展类加载器:用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
    • 即负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  • 系统类加载器:用来加载当前应用 classpath 下的所有 jar 包和类。
    • 可通过ClassLoader.getSystemClassLoader()获取它。
  • 自定义类加载器:通过继承java.lang.ClassLoader类的方式实现。

🌟类加载器基本特征

  • 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的
  • 单一性,父加载器中加载过的类型,不会在子加载器中重复加载

🌟类加载的方式

  • 隐式加载:使用 new + 构造方法,隐式的调用类加载器,加载对应的类到 JVM 中,是最常见的类加载方式。

    • 创建类对象、使用类的静态域、创建子类对象、使用子类的静态域
    • 在 JVM 启动时,通过三大类加载器加载 class
  • 显式加载:使用 loadClass()、forName() 等方法显式的加载需要的类,对于显式加载这种类加载方式来讲,当我们获取到了 Class 对象后,需要调用 Class 对象的 newInstance() 方法来生成对象的实例。

    • ClassLoader.loadClass(className):只加载和链接,不会进行初始化
    • Class.forName(String name, boolean initialize, ClassLoader loader):使用 loader 进行加载和链接,根据参数 initialize 决定是否初始化
//隐式加载
User user = new User();
//显式加载,并初始化
Class clazz = Class.forName("com.test.java.User");
//显式加载,但不初始化
ClassLoader.getSystemClassLoader().loadClass("com.test.java.Parent");

两种类加载方式的区别:

  • 隐式加载能够直接获取对象的实例,而显式加载需要调用 Class 对象的 newInstance() 方法来生成对象的实例。
  • 隐式加载能够使用有参的构造函数,而使用 Class 对象的 newInstance() 不支持传入参数,如果想使用有参的构造函数,必须通过反射的方式,来获取到该类的有参构造方法。

🌟ClassLoader.loadClass(String name)和Class.forName(String name)区别

  • Class.forName得到的class是已经初始化完成的

  • ClassLoder.loaderClass得到的class是没有初始化的

有些情况是只需要知道这个类的存在而不需要初始化的情况使用Classloder.loaderClass,而有些时候又必须执行初始化就选择Class.forName

手写一个类加载器Demo

❸双亲委派

🌟类加载器的双亲委派模型是什么

深入分析ClassLoader,双亲委派机制

双亲委派机制及使用原因

什么是双亲委派模型?

当某个类加载器需要加载某个.class文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。

双亲委派机制:如果一个类加载器收到了类加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

  • 向上委托:如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。如果父类加载器可以完成类加载任务,就成功返回;
  • 向下委派:倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

🌟双亲委派模型的好处

为什么需要双亲委派模型?

讲一下双亲委派模型,以及其优点

使用双亲委派机制加载类的好处就是可以提高系统的安全性和稳定性;

  1. 保证程序稳定性。通过划分不同层级类加载器的职责,使得核心类可以被优先加载,确保了程序的稳定性
  2. 保证程序安全性。该机制可以避免类被重复加载,保证类的全局唯一性,防止类库的核心 API 被随意篡改

如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

🌟双亲委派模型的缺点

为什么要破坏双亲委派?

使用双亲委派存在一定的局限性,在正常情况下,用户代码是依赖核心类库的,所以按照正常的双亲委派加载流程是没问题的;但是在加载核心类库时,如果需要使用用户代码,双亲委派流程就无法满足;即顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类(可见性)

比如在使用JDBC时, 利用DriverManager.getConnection获取连接时,就会存在这样的问题。

DriverManager是由启动类加载器加载的,在加载DriverManager时,会执行其静态方法,加载初始驱动程序,也就是Driver接口的实现类;但是这些实现类基本都是第三方厂商提供的,根据双亲委派原则,第三方的类不可能被根类加载器加载。

🌟破坏双亲委派模型的方式

  • 1.自定义 ClassLoader

    • 如果不想破坏双亲委派模型,只需要重写 findClass 方法
    • 如果想要去破坏双亲委派模型,需要去重写 loadClass 方法
  • 2.引入线程上下文类加载器

    JDK 开发人员引入了线程上下文类加载器(Thread Context ClassLoader),这种类加载器可以通过 Thread 类的 setContextClassLoader 方法设置线程上下文类加载器,在执行线程中抛弃双亲委派加载模式,使程序可以逆向使用类加载器,使 Bootstrap 加载器拿到了 Application 加载器加载的类,破坏了双亲委派模型

    Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的有 JDBC、JCE、JNDI 等。这些 SPI 接口由 Java 核心库来提供,而 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径 classpath 里,SPI 接口中的代码需要加载具体的实现类:

    • SPI 的接口是 Java 核心库的一部分,是由启动类加载器来加载的
    • SPI 的实现类是由系统类加载器加载,启动类加载器是无法找到 SPI 的实现类,因为双亲委派模型中 BootstrapClassloader 无法委派 AppClassLoader 来加载类

    有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

  • 3.实现程序的动态性,如代码热替换(Hot Swap)、模块热部署(Hot Deployment)

    IBM 公司主导的 JSR一291(OSGiR4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换,在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构

    当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:

    1. 将以 java.* 开头的类,委派给父类加载器加载
    2. 否则,将委派列表名单内的类,委派给父类加载器加载
    3. 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载
    4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载
    5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在就委派给 Fragment Bundle 类加载器加载
    6. 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载
    7. 否则,类查找失败

    热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为,热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中

🌟打破双亲委派机制案例:Tomcat

  • 为了Web应用程序类之间隔离,为每个应用程序创建WebAppClassLoader类加载器
  • 为了Web应用程序类之间共享,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebAppClassLoader加载器找不到,则尝试用ShareClassLoader进行加载
  • 为了Tomcat本身与Web应用程序类隔离,用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类
  • 为了Tomcat与Web应用程序类共享,用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器
  • ShareClassLoader、CatalinaClassLoader、CommonClassLoader的目录可以在Tomcat的catalina.properties进行配置

④调优

从实际案例聊聊Java应用的GC优化 - 美团技术团队 (meituan.com)

🌟如何优化 JVM 频繁 minor GC

Minor GC频繁问题,通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低 Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

🌟频繁 full gc 怎么解决?

线下:利用 jvisualvm 去查看内存使用量曲线图,如果内存使用量一直维持在较高水平,那就是堆内存不够,需要调大一点。如果频繁发生抖动,那就是程序频繁生成对象并且进行回收,优化代码,保存可重用的对象不要频繁生成。如果内存使用量一直增长,那就是发生内存泄漏或者内存碎片,需要排查代码或者把 cms 收集器调成 gc 几次就执行一次标记整理算法来搞定内存碎片。
另外要注意虚拟机参数的配置,比如是否只配置了 xmx 而没有配置 xms,是否只设置了 maxNewSize 而没有设置 newSize (这两个参数默认是不一样的,会发生堆收缩)。
如果是 cms 频繁产生大对象的话调整 newradio 的大小来增加新生代的比例也是个不错的思路。因为 cms 在老年代采用标记清除算法,会产生大量内存碎片,使得老年代剩余空间很大但是没有足够的连续空间分配给当前对象 不得不提前触发一次 full gc。
线上:用 jmap -dump 分析哪些类占用比较多就知道哪些类内存泄漏或者 new 的太频繁了。

🌟JVM是如何避免Minor GC时扫描全堆的?

经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。

卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。

🌟什么是指令序列重排序?

简单来说,就是指你在程序中写的代码,在执行时并不一定按照写的顺序。在Java中,JVM能够根据处理器特性(CPU多级缓存系统、多核处理器等)适当对机器指令进行重排序,最大限度发挥机器性能。

Java中的指令重排序有两次,第一次发生在将字节码编译成机器码的阶段,第二次发生在CPU执行的时候,也会适当对指令进行重排。

CMS调优: Java中9种常见的CMS GC问题分析与解决

JVM 是怎么去调优的?了解哪些参数和指令?

JVM性能调优都做了什么?

Java 中如何进行 GC 调优?

有做过JVM内存优化吗?

从SQL、JVM、架构、数据库四个方面讲讲优化思路

JVM的编译优化

jvm性能调优都做了什么

JVM诊断调优工具用过哪些?

jvm怎样调优,堆内存、栈空间设置多少合适

JVM相关的分析工具使用过的有哪些?具体的性能调优步骤如何

如何进行JVM调优?有哪些方法?

如何理解内存泄漏问题?有哪些情况会导致内存泄漏?如何解决?

JVM如何调优、参数怎么调?

JVM诊断调优工具用过哪些?

每秒几十万并发的秒杀系统为什么会频繁发生GC?

日均百万级交易系统如何优化JVM?

线上生产系统OOM如何监控及定位与解决?

高并发系统如何基于G1垃圾回收器优化性能?

公开的方法能重写,底层的 JVM 里面是怎么做到的?

❤️Sponsor

您的支持是我不断前进的动力,如果您感觉本文对您有所帮助的话,可以考虑打赏一下本文,用以维持本博客的运营费用,拒绝白嫖,从你我做起!🥰🥰🥰

支付宝 微信

文章作者: 简简
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 简简 !
评论
填上邮箱会收到评论回复提醒哦!!!
 上一篇
Q&A:JUC Q&A:JUC
①线程❶基础🌟线程和进程的区别 区别 线程 进程 调度 程序执行的基本单位 资源管理的基本单位 资源 独立的内存和资源 共享本进程的地址空间和资源 关系 线程属于进程 进程包含线程 切换 上下文切换快 上下文切换慢
2019-12-27
下一篇 
计网 & OS 计网 & OS
计网①网络分层 应用层(数据):确定进程之间通信的性质以及满足用户需要以及提供网络和用户应用,为应用程序提供服务, 表示层(数据):主要解决用户信息的语法表示问题,提供各种用于应用层数据的编码和转换功能,确保一个系统的应用层发送的数据能
2019-12-17
  目录