Java基础总结(六)——JVM

jvm:类加载机制,垃圾回收策略,垃圾回收算法的好处,常见的调优方式。

类加载机制

类加载

  • 类加载有三个步骤:加载、连接、初始化。
  • **加载:**类加载指的是将class文件读入内存,并为之创建一个java.lang.Class对象。类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在java堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class 对象。Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。类的加载由类加载器完成,JVM提供的类加载器称为系统加载器,此外还可以通过继承ClassLoader基类来自定义类加载器。
  • 通常可以用如下几种方式加载类的二进制数据:
    1.从本地文件系统加载class文件。
    2.从JAR包中加载class文件,如JAR包的数据库启驱动类。
    3.通过网络加载class文件。
    4.把一个Java源文件动态编译并执行加载。
  • **连接:**连接阶段负责把二进制数据合并到jre中,包含三个阶段验证、准备、解析。
    1.验证:确保加载的类信息符合JVM规范,无安全问题。
    2.准备:给类中的静态域分配内存,并按类型赋初值。
    3.解析:将类的二进制数据中的符号引用转换为直接引用。
  • **初始化:**给类中的静态域赋值。有两种方法,一种是在声明时即赋值,一种是在静态代码块中赋值。
  • 类加载的生命周期:加载、连接(验证、准备、解析)、初始化、使用、卸载。

Java9之前的类加载机制:
在这里插入图片描述
JVM的类加载机制主要有以下3种:

  • 全盘负责:当一个类加载器加载某个Class时,该Class所依赖和引用的其它Class也将由该类加载器负责载入,除非显式的使用另外一个类加载器来载入。
  • 双亲委派:当一个类加载器收到了类加载请求,它会把这个请求委派给父(parent)类加载器去完成,依次递归,因此所有的加载请求最终都被传送到顶层的启动类加载器中。只有在父类加载器无法加载该类时子类才尝试从自己类的路径中加载该类。(注意:类加载器中的父子关系并不是类继承上的父子关系,而是类加载器实例之间的关系。)
  • 缓存机制:缓存机制会保证所有加载过的Class都会被缓存,当程序中需要使用某个类时,类加载器先从缓冲区中搜寻该类,若搜寻不到将读取该类的二进制数据,并转换成Class对象存入缓冲区中。这就是为什么修改了Class后需重启JVM才能生效的原因。

Java9在类加载的改变:
DK 9保持三级分层类加载器架构以实现向后兼容。但是,从模块系统加载类的方式有一些变化。且新增Platform ClassLoader:平台类加载器,用于加载一些平台相关的模块,例如: java.activation 、 java.se 、 jdk.desktop 、 java.compiler 等,双亲是BootClassLoader。 JDK 9类加载器层次结构如下图所示。
在这里插入图片描述
JDK 9中的类加载机制有所改变。 三个内置的类加载器一起协作来加载类。

  • 当应用程序类加载器需要加载类时,它将搜索定义到所有类加载器的模块。 如果有合适的模块定义在这些类加载器中,则该类加载器将加载类,这意味着应用程序类加载器现在可以委托给引导类加载器和平台类加载器。 如果在为这些类加载器定义的命名模块中找不到类,则应用程序类加载器将委托给其父类,即平台类加载器。 如果类尚未加载,则应用程序类加载器将搜索类路径。 如果它在类路径中找到类,它将作为其未命名模块的成员加载该类。 如果在类路径中找不到类,则抛出ClassNotFoundException异常。
  • 当平台类加载器需要加载类时,它将搜索定义到所有类加载器的模块。 如果一个合适的模块被定义为这些类加载器中,则该类加载器加载该类。 这意味着平台类加载器可以委托给引导类加载器以及应用程序类加载器。 如果在为这些类加载器定义的命名模块中找不到一个类,那么平台类加载器将委托给它的父类,即引导类加载器。
  • 当引导类加载器需要加载一个类时,它会搜索自己的命名模块列表。 如果找不到类,它将通过命令行选项-Xbootclasspath/a指定的文件和目录列表进行搜索。 如果它在引导类路径上找到一个类,它将作为其未命名模块的成员加载该类。

垃圾回收策略

Java内存区域:程序计数器,Java虚拟机栈,本地方法栈,Java堆,方法区。
其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区的内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

对象已死吗?
1.引用计数算法

  • 给对象中添加一个引用计数器(初始值为1),每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的。
  • 优点:引用计数器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的环境有利。
  • 缺点:不能判断出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

2.可达性分析算法
程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在这里插入图片描述
在Java语言中,可作为GC Roots的对象包括下面几种:
a) 虚拟机栈中引用的对象(栈帧中的本地变量表);
b) 方法区中类静态属性引用的对象;
c) 方法区中常量引用的对象;
d) 本地方法栈中JNI(Native方法)引用的对象。

3.再谈引用

  • 强引用
    在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 软引用
    用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用
    也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

  • 虚引用
    也叫幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。

  • 无论引用计数算法还是可达性分析算法都是基于强引用而言的。

4.对象死亡(被回收)前的最后一次挣扎
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

5.方法区如何判断是否需要回收
方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
加载该类的ClassLoader已经被回收;
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

常用的垃圾收集算法
1.标记-清除算法
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
优点:实现简单,与保守式GC算法兼容
缺点:造成内存碎片,分配速度低,与写时复制技术算法不兼容

2.复制算法(用于回收新生代)
它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于copying算法的垃圾 收集就从根集合(GC Roots)中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
优点:优秀的吞吐量,不会造成内存碎片,可实现高速分配,与缓存兼容
缺点:堆使用效率低,不兼容保守式GC算法,递归调用函数有栈溢出的可能

3.标记-整理算法(用于回收老年代)
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
优点:解决碎片问题,有效利用堆
缺点:成本高

4.分代收集算法

  • 年轻代(Young Generation)的回收算法
    a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
    b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
    c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。
    d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
  • 年老代(Old Generation)的回收算法
    a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
    b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
  • 持久代(Permanent Generation)的回收算法
      用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,具体的回收可参见上文。
  • 优点:吞吐量得到改善
  • 缺点:在部分程序中会起到反作用

常见的垃圾收集器

  • Serial收集器(复制算法)
    新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。
  • Serial Old收集器(标记-整理算法)
    老年代单线程收集器,Serial收集器的老年代版本。
  • ParNew收集器(停止-复制算法) 
    新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
  • Parallel Scavenge收集器(停止-复制算法)
    并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。
  • Parallel Old收集器(停止-复制算法)
    Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。
  • CMS(Concurrent Mark Sweep)收集器(标记-清理算法)
    高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。

GC是什么时候触发的

  • Scavenge GC
    一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。
  • Full GC
    对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:
    a) 年老代(Tenured)被写满;
    b) 持久代(Perm)被写满;
    c) System.gc()被显示调用;
    d) 上一次GC之后Heap(堆)的各域分配策略动态变化;

内存分配&回收策略
1.对象优先在新生代Eden区分配,当Eden区空间不足时, 会触发一次Minor GC

  • Minor GC : 发生在新生代的GC,频繁且速度快
  • Full GC : 发生在老年代的GC
    2.大对象直接进入老年代
    3.长期存活的对象进入老年代
  • 每个对象有一个年龄计数器
  • 在Eden出生,熬过第一次Minor GC并能被Survivor容纳,将移动到Survivor空间
  • 在Survivor每熬过一次Minor GC,年龄会加一岁,年龄增长到一定程度,会晋升老年代
    4.如果Survivor中,所有相同年龄k的对象大小总和>Survivor空间大小/2 ,则年龄大于k的对象直接进入老年代
    5.空间分配担保
  • Minor GC之前,检查老年代最大可用连续空间是否大于新生代所以对象总空间,如果成立,则此次Minor GC是安全的
  • 如果不成立,检查是否允许担保失败参数
    -允许 : 冒风险进行Minor GC(取每次晋升老年代对象大小平均值作为经验值)
    不允许: 进行一次Full GC

常见的调优方式
1.年轻代大小选择

  • 响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生的频率也是最小的。同时,减少到达年老代的对象。
  • 吞吐量优先的应用:尽可能的设置大,可能到达Gbit的程度。因为对响应时间没有要求,垃圾收集可以并行进行,一般适合8CPU以上的应用。

2.老年代大小选择

  • 响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:
    并发垃圾收集信息
    持久代并发收集次数
    传统GC信息
    花在年轻代和年老代回收上的时间比例
    减少年轻代和年老代花费的时间,一般会提高应用的效率
  • 吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代尽存放长期存活对象。

3.较小堆引起的碎片问题
因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。

调优常用工具?

Jconsole,jProfile,VisualVM
Jconsole : jdk自带,功能简单,但是可以在系统有一定负荷的情况下使用。对垃圾回收算法有很详细的跟踪。
JProfiler:商业软件,需要付费。功能强大。
VisualVM:JDK自带,功能强大,与JProfiler类似。推荐。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值