Java JVM

一、Java 内存区域

java虚拟机运行时数据区

1.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。每条线程都拥有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。

1.2 Java虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack)也为线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:

  • 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出 StackOverflowError 异常;
  • 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出 OutOfMemoryError 异常。
1.3 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

1.4 Java堆

Java 堆(Java Heap)是虚拟机所管理的最大一块的内存空间,它被所有线程所共享,用于存放对象实例。Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的,当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx 和最小值参数 -Xms 进行设定。如果 Java 堆中没有足够的内存来完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。

二、对象

2.1 对象的创建

当我们在代码中使用 new 关键字创建一个对象时,其在虚拟机中需要经过以下步骤:

1. 类加载过程

当虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。

2. 分配内存

在类加载检查通过后,虚拟机需要新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:

  • 指针碰撞:假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。
    jvm_指针碰撞
  • 空闲列表:如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。

注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,后文将会介绍。

除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。想要解决这个问题有两个方案:

  • 方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
  • 方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。
3. 对象头设置

将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。

4. 对象初始化

调用对象的构造函数,即 Class 文件中的 <init>() 来初始化对象,为相关字段赋值。

2.2 对象的内存布局

在 HotSpot 虚拟机中,对象在堆内存中的存储布局可以划分为以下三个部分:

1. 对象头 (Header)

对象头包括两部分信息:

  • Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word 。
  • 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的示例。需要说明的是并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式(详见下文)。
2. 实例数据 (Instance Data)

即我们在程序代码中定义的各种类型的字段的内容,无论是从父类继承而来,还是子类中定义的都需要记录。

3. 对其填充 (Padding)

主要起占位符的作用。HotSpot 虚拟机要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。

2.3 对象的访问定位

对象创建后,Java 程序就可以通过栈上的 reference 来操作堆上的具体对象。《Java 虚拟机规范》规定 reference 是一个指向对象的引用,但并未规定其具体实现方式。主流的方式有以下两种:

  • 句柄访问:Java 堆将划分出一块内存来作为句柄池, reference 中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。
  • 指针访问reference 中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。

通过句柄访问对象:
jvm_通过句柄访问对象
通过直接指针访问对象:
jvm_通过直接指针访问对象
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference 本生并不需要修改;指针访问则反之,由于其 reference 中存储的直接就是对象地址,所以当对象移动时, reference 需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。

三、垃圾收集算法

在 Java 虚拟机内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆和方法区上。

3.1 Java 堆回收

在 Java 堆上,垃圾回收的主要内容是死亡对象(不可能再被任何途径使用的对象)。而判断对象是否死亡有以下两种方法:

1. 引用计数法

在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互循环引用的问题:

objA.instance = objB
objB.instance = objA    
objA = null;
objB = null;
System.gc();

如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。

2. 可达性分析

上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。可达性分析是通过一系列被称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,这代表 GC Roots 到该对象不可达, 此时证明此该对象不可能再被使用。
jvm_可达性分析
在 Java 语言中,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
  • 在方法区中类静态属性引用的对象,譬如 Java 类中引用类型的静态变量;
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;
  • 在本地方法栈中的 JNI(即 Native 方法)引用的对象;
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
  • 所有被同步锁(synchronized 关键字)持有的对象;
  • 反应 Java 虚拟机内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots 集合。

3. 对象引用

可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:

  • 强引用 (Strongly Reference) :最传统的引用,如 Object obj = new Object() 。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用 (Soft Reference) :用于描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常之前,会被列入回收范围内进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用 (Weak Reference) :用于描述那些非必须的对象,强度比软引用弱。被弱引用关联对象只能生存到下一次垃圾收集发生时,无论当前内存是否足够,弱引用对象都会被回收。
  • 虚引用 (Phantom Reference) :最弱的引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。
4. 对象真正死亡

要真正宣告一个对象死亡,需要经过至少两次标记过程:

  • 如果对象在进行可达性分析后发现 GC Roots 不可达,将会进行第一次标记;
  • 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalized() 方法。如果对象没有覆盖 finalized() 方法,或者 finalized() 已经被虚拟机调用过,这两种情况都会视为没有必要执行。如果判定结果是有必要执行,此时对象会被放入名为 F-Queue 的队列,等待 Finalizer 线程执行其 finalized() 方法。在这个过程中,收集器会进行第二次小规模的标记,如果对象在 finalized() 方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。
3.2 方法区回收

在 Java 堆上进行对象回收的性价比通常比较高,因为大多数对象都是朝生夕灭的。而方法区由于回收条件比较苛刻,对应的回收性价比通常比较低,主要回收两部分内容:废弃的常量和不再使用的类型。

3.3 垃圾收集算法
1. 分代收集理论

当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:

  • 弱分代假说 (Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说 (Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
  • 跨带引用假说 (Intergenerational Reference Hypothesis):基于上面两条假说还可以得出的一条隐含推论:存在相互引用关系的两个对象,应该倾向于同时生存或者同时消亡。

强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象经历垃圾收集的次数)分配到不同的区域中进行存储。之后如果一个区域中的对象都是朝生夕灭的,那么收集器只需要关注少量对象的存活而不是去标记那些大量将要被回收的对象,此时就能以较小的代价获取较大的空间。最后再将难以消亡的对象集中到一块,根据强分代假说,它们是很难消亡的,因此虚拟机可以使用较低的频率进行回收,这就兼顾了时间和内存空间的开销。

2. 回收类型

根据分代收集理论,收集范围可以分为以下几种类型:

  • 部分收集 (Partial GC):具体分为:
    • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
    • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
    • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。
3. 标记-清除算法

它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。
jvm_标记清除算法
它主要有以下两个缺点:

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

标记-复制算法基于 ”半区复制“ 算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块的内存使用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的那块内存空间一次性清理掉。其优点在于避免了内存空间碎片化的问题,其缺点如下:

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

标记-整理算法是在标记完成后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。其优点在于可以避免内存空间碎片化的问题,也可以充分利用内存空间;其缺点在于根据所使用的收集器的不同,在移动存活对象时可能要全程暂停用户程序:
jvm_标记移动算法

四、经典垃圾收集器

并行与并发是并发编程中的专有名词,在谈论垃圾收集器的上下文语境中,它们的含义如下:

  • 并行 (Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,此时通常默认用户线程是处于等待状态。
  • 并发 (Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。但由于垃圾收集器线程会占用一部分系统资源,所以程序的吞吐量依然会受到一定影响。

HotSpot 虚拟机中一共存在七款经典的垃圾收集器:
jvm_hotspot_垃圾收集器

注:收集器之间存在连线,则代表它们可以搭配使用。

4.1 Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,它是一个单线程收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,直到收集结束,这是其主要缺点。它的优点在于单线程避免了多线程复杂的上下文切换,因此在单线程环境下收集效率非常高,由于这个优点,迄今为止,其仍然是 HotSpot 虚拟机在客户端模式下默认的新生代收集器:
jvm_收集器1

4.2 ParNew 收集器

他是 Serial 收集器的多线程版本,可以使用多条线程进行垃圾回收:
jvm_收集器2

4.3 Parallel Scavenge 收集器

Parallel Scavenge 也是新生代收集器,基于 标记-复制 算法进行实现,它的目标是达到一个可控的吞吐量。这里的吞吐量指的是处理器运行用户代码的时间与处理器总消耗时间的比值:
吞 吐 量 = 运 行 用 户 代 码 时 间 运 行 用 户 代 码 时 间 + 运 行 垃 圾 收 集 时 间 吞吐量 = \frac{ 运行用户代码时间 }{ 运行用户代码时间+运行垃圾收集时间 } =+
Parallel Scavenge 收集器提供两个参数用于精确控制吞吐量:
-XX:MaxGCPauseMillis:控制最大垃圾收集时间,假设需要回收的垃圾总量不变,那么降低垃圾收集的时间就会导致收集频率变高,所以需要将其设置为合适的值,不能一味减小。
-XX:MaxGCTimeRatio:直接用于设置吞吐量大小,它是一个大于 0 小于 100 的整数。假设把它设置为 19,表示此时允许的最大垃圾收集时间占总时间的 5%(即 1/(1+19) );默认值为 99 ,即允许最大 1%( 1/(1+99) )的垃圾收集时间。

4.4 Serial Old 收集器

从名字也可以看出来,它是 Serial 收集器的老年代版本,同样是一个单线程收集器,采用 标记-整理 算法,主要用于给客户端模式下的 HotSpot 虚拟机使用:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

骑士梦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值