深入理解Java虚拟机

深入理解Java虚拟机

1.JVM概述

  • JDK:软件开发工具包 。
    Java程序设计语言 + Java虚拟机 + JavaAPI类库,是支持Java程序开发的最小环境
  • JRE:Java 运行时环境
    Java API + JVM ,是支持Java程序运行的标准环境
Java程序的执行流程

Java源码 —— 编译器 —— JVM可执行的Java字节码(class文件) —— JVM —— JVM解释器 ——机器可执行的二进制文件 —— 程序运行

  • 编译型语言:通过编译器,将源代码编译成机器码后才能执行的语言。
    优点:编译只做一次,执行效率高
    缺点:根据不同的操作系统需要生成不同的可执行文件

  • 解释型语言:不需要编译,在运行程序时才逐行进行翻译
    优点:平台兼容性好
    缺点:每次运行时都要解释一遍,性能低

  • Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。 由于字节码并不专对一种特点的机器,因此Java程序无须重新编译便可在多种不同的机器上运行。 可以做到“一次编写,到处运行”。

2.内存模型

运行时数据区域

线程私有的区域:

  • 程序计数器:相当于一个程序执行过程中的行号指示器,指向当前执行的虚拟机字节码地址。如果执行的是Java方法,计数器就记录者正在执行的虚拟机字节码指令的地址。如果是native 方法,计数器为空
  • 虚拟机栈:虚拟机栈就是java方法的内存模型,每一个线程在执行时会有自己的一个虚拟机栈,在运行过程中把所调用方法封装为一个栈帧,然后将栈帧存放在栈里面。栈帧包含了一个方法执行时的相关信息,包括方法用到的局部变量,操作数,动态链接等。 每个方法在调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈到过程。
  • 本地方法栈:类似于虚拟机栈,只不过他存放的是Native方法。

线程共享的区域

  • :用来存放所有线程创建的类的对象实例。方法调用中如果创建了对象,会把这个对象实例存放在堆,然后将对于这个对象的引用存放在栈中。 垃圾收集器管理的主要区域。
    Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
  • 方法区:存放虚拟机加载的类的信息(类的版本,字段,方法,接口)和一些常量、静态变量,即时编译器编译后的代码等,这些内容一般是不可变的。
    方法区有时被称为持久代(PermGen)

其他区域

  • 直接内存:不直接在Java堆中分配内存,而是直接分配堆外内存,然后通过一个存储在Java 堆中的对象作为这块内存的引用进行操作。
OOM和StackOverFlow
  • StackOverFlow: 虚拟机栈会把每次调用的方法作封装为一个栈帧存起来。这些栈帧肯定是要占内存的,而栈的内存也是有限的。如果栈帧很多一直没有释放,这时候又来了一个栈帧,这个栈帧已经没有空间可以容纳了,有两种情况。如果这种虚拟机栈不支持动态扩展,那么将会抛出StackOverFlow异常。如果支持动态扩展,那么这个栈会请求再扩展部分空间。当然内存不是无穷的,如果频繁的扩展内存,以至于无法再继续扩展了,这时候会抛出OutOfMemory异常。
  • OutOfMemory
    以下几种情况会产生OOM异常:
  1. Java堆溢出:不断创建对象且没被回收
  2. 虚拟机栈和本地方法栈溢出:线程不断迭代
    由于线程过多导致的内存溢出,只能减少最大堆和减少栈容量来换取更多的线程。
  3. 方法区和运行时常量池溢出:一直加载新的类(类的回收条件很苛刻),如经常动态生成大量Class的应用,需要特别注意类的回收状况。
对象的创建
  1. 虚拟机发现new指令后,会先看看new 后面跟着的那个参数能否在常量池中定位到一个类的符号引用,并且检查那个类是否已经被加载过。如果没有,则进行一次类的加载工作。
  2. 加载完成后,虚拟机会为新的对象在堆中分配一块内存,具体分配多少,在类加载完之后其实就已经定了。(指针碰撞,空闲列表)

HotSpot 使用了称之为 Thread-Local Allocation Buffers (TLABs) 的技术,该技术能改善多线程空间分配的吞吐量。首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区中进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了它的 TLAB,它才需要使用同步来获取一个新的缓冲区。HotSpot 使用了多项技术来降低 TLAB 对于内存的浪费。比如,TLAB 的平均大小被限制在 Eden 区大小的 1% 之内。TLABs 和使用指针碰撞的线性分配结合,使得内存分配非常简单高效,只需要大概 10 条机器指令就可以完成。

  1. 分配完内存,之后会将这个对象的实例字段初始化为零值。
  2. 对对象进行一些设置,比如设置哈希码,分代年龄信息,这个对象属于哪个类之类的。并把这些信息存放在对象头中。
  3. 执行方法,按程序员意愿进行初始化
对象的内存布局
  • 对象头:包括自身运行时数据(如哈希码,GC分代年龄,锁状态标志),类型指针(指向类元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。
  • 实例数据:对象真正存储的有效信息
  • 对齐填充
对象的访问定位
  • 创建好一个对象,还需要一个引用来持有他才能使用。引用是放在虚拟机栈栈帧的本地变量表中的。
  • 引用有两种形式,一种是直接持有对象地址,一种是持有一个句柄,句柄保存在堆中,包含着对象的地址,是间接访问。直接访问速度快,间接访问在对象频繁移动时比较有优势。
从永久代到元空间
  • 由于永久代内存经常不够用或发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen,JDK8废除了永久代,改用元数据。

  • 元空间是方法区的在HotSpot jvm 中的实现,方法区主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。

  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

3.垃圾收集器与内存分配策略

如何确定对象已死(如何判定对象是垃圾对象)
  • 引用计数法:为每一个对象设一个值,用来计算被引用的次数。只要有一个对于对象的引用存在,就让这个数字加一。这样如果一个对象没有任何引用,那么引用计数为零,这个对象就会被标记为“可回收”。 但是这样有一个很严重的bug,那就是如果我有两个对象,已经不再使用,但是他们互相引用,那么他们的引用计数就永远不会为零,那么就不会被回收。
  • 可达性分析算法:将一些特定的对象作为GC Roots,然后从这个节点向下寻找对其他对象的引用。如果一个对象到GC Roots没有引用链,那么就可以被回收了。在Java虚拟机中,被规定作为GC Roots的对象有:
  1. 虚拟机栈中引用的对象 2.方法区中静态属性引用的对象
  2. 方法区中常量引用的对象 4.Native方法引用的对象
  • 日常开发过程中遇到的内存泄漏,很大一部分原因就是本该被回收的对象无意之中被GC Roots引用到了,比如写的static这样的静态字段引用的对象,这样他就不会被回收了
  • 另外,宣告不可达对象到死亡需要经历两次标记过程:
  1. 发现不可达,被第一次标记并筛选(筛选条件:有没有覆盖finalize()方法,finalize()方法有没有被调用过(一个对象到finalize()方法只会调用一次),如果没覆盖或被调用过,那么没必要再执行finalize()了,它已经玩完了)
  2. 如果有必要,对象被放置在F-Queue队列中,并之后被虚拟机触发finalize()(最后一次逃脱过程),并将F-Queue中的对象进行第二次小规模标记;如果两次标记都没有逃脱,那它也玩完了。
四种引用

Java中引用有四种,分别是强、软、弱、虚。这四种引用的区别就在于GC的过程中:

  • 强引用:直接通过类名new一个对象,这样直接创建的对对象的引用叫做强引用。被强引用的对象,一般是不会被回收掉的。
    Object obj = new Object();
  • 软引用:被软引用持有的对象,只有在“不回收就要内存溢出”的时候,才会回收
    SoftReference sf = new SoftReference(obj);
  • 弱引用:被弱引用持有的对象,在每次GC都会被回收
    WeakReference wf = new WeakReference(obj);
  • 虚引用:无任何时机作用,只是一个标记,为了能使对象被回收时做一些系统通知什么的
    PhantomReference pf = new PhantomReference(obj);
垃圾收集算法
  • 标记—清除算法(Mark-Sweep)

分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;

清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header信息),则将其回收。

不足:标记和清除过程效率都不高,会产生大量碎片,内存碎片过多可能导致无法给大对象分配内存。
深入理解Java虚拟机

  • 复制算法(Copying)

将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和 使用过的那一块 Survivor。

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

不足:

将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
深入理解Java虚拟机

  • 标记—整理算法(Mark-Compact)

标记—整理算法和标记—清除算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存,因此其不会产生内存碎片。标记—整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。

不足:

效率不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
深入理解Java虚拟机

  • 分代收集算法(Generational Collection)

分代回收算法实际上是把复制算法和标记整理法的结合,并不是真正一个新的算法,一般分为:老年代(Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。

新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。

老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对象需要回收,故采用标记清除或者标记整理算法

GC触发条件
  • GC的类型:
  1. Minor GC: 发生在新生代的GC,频繁,回收速度块
  2. Full GC: 发生在老年代的GC,速度慢10倍以上

每次分配内存,如果对象比较大的话直接进入老年代。否则,先进入Eden区和一个Survivor区,同时会为每一个对象设一个年龄值。之后会周期性的在某个安全点(Savapoint)。 检查一下,当Eden区满时,对于新生代的对象,将可回收的对象回收掉,将剩余的对象复制到另一个Survivor区,这一过程中会对年龄值加一。(空间分配担保:如果此时Survivor放不下了,则直接进入老年代),这一过程叫做Minor GC,是属于新生代的GC。

当某些对象年龄值比较大时,会将他们移动到老年代去(此外还有动态年龄判定:当Survior空间中相同年所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。)当然在这之前会先查看一下老年代剩余空间是否满足移动。如果不能满足,就会对老年代进行一次GC,这一过程叫做Full GC。而这个检查对象是否可GC得时机,也就是GC的时机,一般是确定的被称作“安全点”。在这一时机进行检查,是不会影响程序正常运行的。

除此之外,其他情况也会发生Full GC:

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

(2)老年代空间不足

(3)方法去空间不足

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

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

关于安全点和安全区域

在这之前,需要提到一个概念:Stop the world

Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。

上面讲到,线程需要在“安全点”进行检查是否可GC,如果可以则进行GC,因此到了安全点时,需要Stop-The-World,具体方式为主动式中断(设置一个标志,和安全点重合,各个线程主动轮询这个标志,发现中断标志为真就挂起自己。)

安全点的选定原则:“是否具有让程序长时间执行”,即执行序列可以复用,一般选在方法调用,循环跳转,抛出异常。

安全区域:线程阻塞时无法到安全点挂起,故采用Sava Region
安全区域指在一段代码片段中,引用关系不会发生变化,在该区域任何地方发生GC都是安全的,当代码执行到安全区域时,标识自己已进入安全区域,如果在这段时间里JVM发起GC,就不用管在安全区域里的线程了;在线程离开安全区时,会检查系统是否进行GC,如果是,则GC完成后再离开安全区域。

OopMap:快速找到GC Roots
垃圾收集时,需要对全局性引用和执行上下文进行检查,如果要逐个检查这里面的引用,消耗太大。因此通过OopMap来记录哪些地方存放着对象引用,哪些地方没有存放对象引用,消耗太大。
OopMap的更新也在安全点完成。

垃圾收集器详解
  • 新生代收集器:
  1. Serial收集器:单线程收集器
  2. ParNew收集器:Serial收集器的多线程方法(可以和CMS配合工作)
  3. Parallel收集器:可控的吞吐量(运行用户代码时间/(运行时间+GC时间))CPU利用率高,适合尽快完成运算任务,不需要太多交互的任务
  • 老年代收集器
  1. Serial Old 收集器:单线程收集器,CMS的后备方案
  2. ParNew Old 收集器:ParNew收集器的老年代版本 多线程+标记整理
  3. CMS 收集器:以获取最短回收停顿时间为目标的收集器(适用于应用程序要求低停顿,同时能接受在垃圾收集阶段和垃圾收集线程一起共享 CPU 资源的场景,典型的就是 web 应用),基于标记清除

CMS 收集过程首先是一段小停顿 stop-the-world,叫做 初始标记阶段(initial mark),用于确定 GC Roots。然后是 并发标记阶段(concurrent mark),标记 GC Roots 可达的所有存活对象,由于这个阶段应用程序同时也在运行,所以并发标记阶段结束后,并不能标记出所有的存活对象。为了解决这个问题,需要再次停顿应用程序,称为 再次标记阶段(remark),遍历在并发标记阶段应用程序修改的对象(标记出应用程序在这个期间的活对象),由于这次停顿比初始标记要长得多,所以会使用多线程并行执行来增加效率。

再次标记阶段结束后,能保证所有存活对象都被标记完成,所以接下来的 并发清理阶段(concurrent sweep) 将就地回收垃圾对象所占空间。

如果老年代空间不足以容纳从新生代垃圾回收晋升上来的对象,那么就会发生 concurrent mode failure,此时会退化到发生 Full GC,清除老年代中的所有无效对象,这个过程是单线程的,比较耗时

另外,即使在晋升的时候判断出老年代有足够的空间,但是由于老年代的碎片化问题,其实最终没法容纳晋升上来的对象,那么此时也会发生 Full
GC,这次的耗时将更加严重,因为需要对整个堆进行压缩,压缩后年轻代彻底就空了。

并发标记和并发清理两个耗时最长的阶段不需要STW,可以和用户线程并发执行,因此缩小了响应时间;但初始标记阶段和再次标记阶段还是需要STW的。

缺点:CPU资源敏感,浮动垃圾

G1 垃圾收集器

G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,这一点非常重要。

G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间

如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。

首先是内存划分上,之前介绍的分代收集器将整个堆分为年轻代、老年代和永久代,每个代的空间是确定的。

而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。
深入理解Java虚拟机
执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间,这也是为什么 G1 被取名为 Garbage-First 的原因。

在 G1 中,目标停顿时间非常非常重要,用 -XX:MaxGCPauseMillis=200 指定期望的停顿时间。

G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收

我们要知道 G1 不是一个实时收集器,它会尽力满足我们的停顿时间要求,但也不是绝对的,它基于之前垃圾收集的数据统计,估计出在用户指定的停顿时间内能收集多少个区块。

注意:G1 有和应用程序一起运行的并发阶段,也有 stop-the-world 的并行阶段。但是,Full GC 的时候还是单线程运行的,所以我们应该尽量避免发生 Full GC,后面我们也会介绍什么时候会触发 Full GC。

G1工作流程:
1.初始标记 2.并发标记(只有该阶段是并发的
3.最终标记 4.筛选回收

  • G1 参数配置和最佳实践
    https://blog.****.net/a724888/article/details/78764006