我的复习笔记-JVM & 垃圾回收 & 类加载机制

510 阅读13分钟

一、垃圾回收

JVM如何定位垃圾

  • 引用计数法
  • 可达性分析算法(JAVA使用)

可达性分析: 也叫索根算法,从GC root 作为起点向下搜索,所走过的路径为 「引用链」 ,当一个对象没有任何引用链达到GC root即代表对象「不可达,可被回收」

引用计数法: Java堆中每一个具体的对象都有一个 「引用计数器」 ,当这个对象被初始化和引用的时候这个计数器就会进行+1。当引用失效后就会进行-1。当垃圾回收时所有引用为0的对象都会被回收

  • 缺点: 无法处理循环依赖,引用计数器增加了程序执行的开销

GC roots

可作为GC roots的对象一共四种

  • 本地方法栈(Native方法)
  • 方法区:常量
  • 方法区:静态变量

引用链

可回收对象

Finalize()二次标记

一个对象被回收会经历两个过程,

  • 第一个过程是被标记为垃圾对象,经过第一次标记后还会对对象进行第二次标记。
  • 第二次标记是判断对象是否需要执行finalize方法。如果这个对象需要执行就会被放到一个叫F-queue的队列中,等待执行

注意:由于fianlize是由一个执行级别较低的线程进行执行的,不能保证fianlize一定会被执行,即时被执行了也不一定会执行完。

2、对象引用

强引用(Strong Reference)

只要强引用存在,「不会被回收」 , 如代码中普遍存在的 Object obj = new Objetc()

软引用(Sofe Reference)

非必须存在的,可以用 SofeReference来实现软引用
JVM内存充足时不会进行回收,内存块溢出的时候回收

弱引用(Weak Reference)

可通过WeakReference内来实现弱引用
只要GC就会进行回收

虚引用(Phatnom Reference)

虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,JDK提供了PhantomReference类来实现虚引用。为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收时收到一个系统通知。(我也不知道啥意思,复制的)

二、垃圾回收算法(理论)

标记-清除算法

通过可达性分析找到可以清除的对象,然后进行回收

  • 优点:实现简单,不需要对对象进行移动
  • 缺点:标记清除时的效率低下。会产生大量不连续的内存碎片

标记-整理算法(标记压缩)

执行原理和标记清除一样,但是清理完之后会进行内存压缩来达到去除内存碎片的目的;

  • 优点:没有内存碎片
  • 缺点: 需要对对象进行移动,一定程度上降低了效率

复制算法

它将内存分为两个内存块,每次仅使用一半的空间。当一半的空间用完时进行GC,把可达对象移动到另一半空间中,然后对原有空间进行全部清理

  • 优点:不会产生内存碎片
  • 缺点:只能使用一半的JVM内存,而且会对长时间存活的对象进行频繁复制

分代收集算法(目前JVM主流)

分为 年轻代,老年代,永久代(JDK7后移除永久代,元空间替代)

新生代

对大多数对象刚创建时会被分配到这里,新生代的垃圾回收叫 Minor GC (复制算法), 新生代中有两个区 Eden区和Survivor区(S0区和S1区,分配比例8:1),对象首先会被分配到Eden区(如果对象过大会被分配到老年代中),当对象经历了第一次GC还存活的话会被分配到S0或S1区

分配比例:Eden:s0:s1 = 8:1:1 通过 -XX:SurvivorRatio来设定比例

老年代
  • 当对象经历了15次GC后仍然存活会被移动到老年代
  • 其占用空间比新生代大得多(大概2/3)
  • 老年代的GC次数比新生代要少得多
  • 老年代的垃圾回收叫Major GC(标记压缩算法),全体回收叫 Full GC
永久代

JDK7以后移除了永久代,直接使用MetaSpace替代

为啥要移除永久代:为了防止内存溢出,永久代的对象一搬都是长存活对象会给GC带来不必要的复杂度;

三、垃圾回收器(回收算法的实现)

JDK8默认的回收器是: Parallel GC

  • Minor GC: 回收新生代
  • Major GC:回收老年代
  • Full GC: 全部回收,会Stop the world很耗时,线上环境要尽量减少Full GC

JVM中具体的垃圾回收实现有7种:

  • 新生代: Serial、ParNew、Parallel Scavenege
  • 老年代: Serial Old 、 Parallel Old 、CMS
  • 堆回收: G1

单线程回收器 (Serial、Serial Old)

1.单线程回收器在单核CPU环境下比较适用 2.新生代采用的复制算法 3.老年代采用的标记-整理算法

多线程回收器 (ParNew、Parallel Scavenege、 Parallel Old )

  • ParNew:(用于新生代,复制算法) 是由Serial演化而来的(多线程版本),根据CPU的核数来开启相应线程进行清理
  • Parallel Scavenage: (用于新生代 ,复制算法) 自动控制回收的线程数来达到运行时的高吞吐量
  • Parallel Scavenage Old: (用于老年代,标记压缩算法) 吞吐量优先原则

CMS

最短的回收停顿时间为前提的回收器,采用标记清除算法; 清除的过程分为四个步骤:

  • 初始标记:(耗时最快)会停顿用户线程,找到所有可达对象
  • 并发标记:(耗时长)并发执行,找打所有可达对象
  • 最终标记:(耗时比初始长)需要停顿用户线程,修正之前标记的可达对象
  • 并发清除: 清除阶段

缺点:
1.采用的标记清除算法,会导致内存碎片
2.在并发清除时如果产生新的垃圾(称为浮动垃圾),需要在下一次GC时才能清除
3.比较依赖CPU的核数(开启的线程数=CPU数*3 /4),如果是低于四核的CPU会对用户线程产生影响

G1回收器

STW: Stop The World 这个值用户可以指定

G1回收器最大支持2048个区域,每个区域的大小是1-32M(用户可指定),每个区域内又分成若干个512Byte的卡片(使用Remembered Set 进行存储)

  • 整体采用标记压缩,局部采用 复制算法。 不会产生内存碎片
  • G1也可以使用 -Xms/-Xmx来指定堆空间大小
  • G1回收器是1.7中正式使用用于取代CMS的回收器。他虽然没有物理隔断新生代和老年代,但任然属于分代回收器
  • G1回收器会将堆分成大小相等的Region(不需要相互连续),并跟踪每个Region的垃圾堆积价值大小,在后台维护一个优先列表,根据允许的回收时间回收价值最大的Region;
  • G1采用 Remembered set 来存放Region之间的对象引用,当GC时根据Remembered set的引用情况去搜索,从而避免全堆扫描;

G1

执行流程

  • 初始标记:(STW)标记可达的对象
  • 并发标记:并发对已经初始标记的对象进行标记
  • 最终标记:(STW)重新对可达对象进行标记
  • 筛选回收: 评估标记的垃圾,根据GC的停顿时间来评估回收计划(垃圾堆积价值优先列表)

四、类加载

类加载

ClassLoader类(抽象类)

所有自定义的类加载器都需要实现这个类
主要的四个方法

  • loadClass(): 双亲委派模式的代码实现,通过findLoadedClass(name)方法检查这个类是否已经被加载,如果没有加载就委托父类进行加载;
  • findClass(): 自己加载这个类(自定义类加载器主要重写这个方法)
  • defineClass(): 将byte自己解析成JVM能解析的Class对象
  • resolveClass(): 类加载过程中的 解析

类加载器的命名空间(必须理解)

父加载器加载的类不能访问子加载器加载出来的类

双亲委派机制

三大系统类加载器:

  • 启动类加载器 Bootstrap classLoader (C语言编写,只加载JAVA_HOME\lib中的Jar包)
  • 扩展类加载器 Extension classLoader(Java编写,只加载JAVA_HOME\lib\ext中的Jar包)
  • 应用加载器 Appliaction classLoader(Java编写 ,系统默认的加载器,加载项目中Classpath中的包)

双亲委派机制的理解: 在加载一个类的时候先判断我又没有加载过这个类,如果没有加载过就委托他的父加载器去加载(循环直至没有父类),如果父类无法加载这个类就交由加载器自身去进行加载

双亲委派的好处: 1)保护了核心代码不背篡改(因为所有的类最后都会到父顶级父加载器那去加载一遍,如果加载过了就不会加载); 2)保证了所有的类只会被加载一次,避免重复加载

打破双亲委派机制

在第一次类加载的时候就会设置线程上下文加载器为ApplicationClassloader (查看 sun.misc.Launcher的构造方法)

设置线程上下文加载器的用处就是因为启动类加载器中定义了某些规范需要在应用加载器中实现(各个厂商实现的规范,如JDBC),但是由于类加载器命名空间的问题(父类加载器无法访问子类加载器加载的类)所以需要用到应用加载器去加载;

自定义类加载器

继承ClassLoder类,覆盖findClass()方法;

热部署类加载器

因为双亲委派的机制,所有的类只会被加载一遍。想要实现热部署就需要自行实现一个类加载,在编写的时候不通过loadClass方法去加载,改用findclass方法去直接加载(绕过缓存),从而实现热部署

TomCat的类加载模式

Tomcat的类加载是没有遵循双亲委派机制的

最先加载的是 Webapp ClassLoader 加载不到了才会给 Common ClassLoader加载(走双亲委派机制)

五、虚拟机(JVM)

  • JVM不仅仅只能运行Java程序,还能运行Scala,Groovy,kotlin等多种语言(目前约10种)
  • JVM官方有一套官方规范,只要符合规范的文件都可以在JVM中运行
  • 目前主流虚拟机叫 HotSpot(Oracle的),还有N多其他的公司的虚拟机,比如 JRockit VM (SUN公司),OpenJDK(开源的), IBM的J9 VM 等等;
  • JDK8的方法区改名为MetaSpace(元空间),直接存放在机器内存上

JVM调优(网络找的)

虚拟机优化的原则是根据实际的业务场景来调整的,不是凭经验拍脑袋来做的;

  • 先预估一下服务器没秒大概需要生成多少M的对象(垃圾对象);
  • 结合JVM内存模型进行调整,例: -Xms=3072m -Xmx=3072m -Xmn=2048m 那个这个JVM内存大小应为 新生代1G(8:1:1 Eden区1.6G,S0和S1区各200m),老年区1G

题目1 假设一个订单系统没秒300单(高峰期),计算得出每秒产生60M的对象,一个单需要20秒处理完成,设置堆内存为3G其他不设置; 根据内存模型得出内存占比:

  • 老年代(占用2/3):2G
  • 新生代:1G ,按照8:1:1得出 Eden区为800M,S0和S1各100m

解题思路:

  • 每秒60M 大概13秒沾满内存,但是一个订单需要20秒才能完成,所以这个13秒内产生的数据都不会清理(移动至老年代),而且S0和S1区大小只有100M ,根据动态年龄分配机制(垃圾对象大于S区50%直接移动到老年区)
  • 每13秒移动800M内存到老年代,老年代大概50秒占满,得出结论这个样设置大概每1分钟触发Full GC(优化原则要尽量减少Full GC)
  • 只需调整一下老年区内存大小即可解决问题 -Xmn=1G

在JVM启动参数中,可以设置跟内存、垃圾回收相关的一些参数设置,默认情况不做任何设置JVM会工作的很好,但对一些配置很好的Server和具体的应用必须仔细调优才能获得最佳性能。通过设置我们希望达到一些目标:

  • GC的时间足够的小
  • GC的次数足够的少
  • 发生Full GC的周期足够的长

前两个目前是相悖的,要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。

  • 针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值
  • 年轻代和年老代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率NewRadio来调整二者之间的大小,也可以针对回收代,比如年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小
  • 年轻代和年老代设置多大才算合理?这个我问题毫无疑问是没有答案的,否则也就不会有调优。我们观察一下二者大小变化有哪些影响

更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC
更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率

如何选择应该依赖应用程序对象生命周期的分布情况:
如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。但很多应用都没有这样明显的特性,在抉择时应该根据以下两点

  • 本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理
  • 通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间