众所周知Java作为高级编程语言是不需要程序员去手动释放内存垃圾(垃圾指的是死亡的对象所占据的堆空间),JVM会替我们完成。那么JVM是如何识别出来哪些是需要被回收的对象?JVM是如何整理内存空间?JVM GC(Garbage Collection)过程和应用程序的线程有哪些相互影响?

如何识别垃圾对象-识别算法

程序员不需要手动编码精准释放不用的对象,那么JVM是如何做到自动识别的呢?

  • 引用计数法(reference counting)

    做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦一个对象的引用计数器为0,则说明该对象已经死亡,它所占用的堆空间可以被回收。

    使用此类算法的有 Python、Objective-C、Per l等。

    优点:

    ​ 算法简单,容易实现:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。

    缺点:

    1. 需要额外的空间存储计数器,和繁琐的计数器更新。

    2. 无法解决循环引用造成内存泄漏。

      GC roots path

      对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。

    • 可达性分析(JVM采用)

      将一系列GC Roots作为初始的存活对象合集(Live Set),然后从该集合出发,探索所有能被该集合引用到的对象,将其加入该集合中,这一过程也被称作标记(Mark)。最终未被探索到的对象便是死亡的,是可以回收的。

      GC Roots可以理解为由堆外指向堆内的引用,一般包括但不仅限于以下:

      1. Java方法栈中的布局变量。Java内存布局
      2. 已加载类的静态变量。
      3. 本地方法栈中Native方法引用的对象
      4. 虚拟机栈(栈帧中本地变量表)中引用的对象
      5. 已经启动且未停职的线程。

回收算法-垃圾回收器的工作原理

由上一步的识别算法找到需要被回收的对象后,接下来就是需要将识别出来的垃圾对象所占用的内存空间进行回收,以便于进行再次利用。回收的算法主要包括:

基础算法:

  1. 标记-清除算法
  2. 标记-压缩算法
  3. 标记-复制算法

改进算法(由上边的算法演进而来):

  1. 分代算法
  2. 增量算法
  3. 并发算法

标记-清除(Sweep)

把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(Free List)中。当需要新建对象时,内存管理便会从空闲列表中寻找空闲内存,并划分给新建对象。

img
优点:

原理,实现比较简单

缺点
  1. 造成内存碎片,由于Java虚拟机的堆中对象必须是连续分布,因此可能出现空闲内存足够,但是无法分配的极端情况。
  2. 分配效率极低,如果是连续的内存空间我们可以通过指针加法(Pointer bumping)来做分配。而对于空闲列表,java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

标记-压缩(Compact)

把存活的对象挪到内存的起始位置。

img
优点

这样做能够开辟出连续的空间,解决内存碎片化问题。

缺点

压缩算法的性能开销比较大。

标记-复制(Copy)

把内存区域划分为两份,分别使用fromto来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活对象复制到to指针指向的内存区域中,并且交换from指针和to指针指向的内容。

img
优点:
  • 不会发生碎片化
  • 优秀的吞吐率
  • 可实现高速分配
  • 良好的locality
缺点:

由于to指针指向的区域始终是空闲的,所以空间利用率极低。

分代算法(Generation)

分代算法基于这样一种假说(Generational Hypothesis):绝大多数对象都是朝生夕死的。分代算法把对象分为几代,新生成的对象称之为:新生代,负责对新生代进行垃圾回收的叫minor GC。到达一定年龄的对象则称之为老年代对象,面向老年代GC的叫major GC。新生代到老年代的过程称之为:晋升(Promotion)注:代数并不是划分的越多越好,虽然按照分代假说,如果分代数越多,最后抵达老年代的对象就越少,在老年代对象上消耗的垃圾回收的时间就越少,但分代数增多会带来其他的开销,综合来看,代数划分为 2 代或者 3 代是最好的。

分代算法由于其普适性,已经被大多数的垃圾回收器采用(ZGC 目前不支持,但也在规划中了)。

增量算法(Increment)

增量算法对基础算法的改进主要体现在该算法通过并发的方式,降低了 STW 的时间。下图是增量算法和基础的标记-清除算法在执行时间线上的对比,可以看到,增量算法的核心思想是:通过 GC 和应用程序交替执行的方式,来控制应用程序的最大暂停时间。

image.png

增量算法的「增量」部分,主要有「增量更新(Incremental Update)」和「增量拷贝(Incremental Copying)」两种,前者主要是做「标记」增量,后者是在做「复制」增量。

并发算法(Concurrent)

广义上的并发算法指的是在 GC 过程中存在并发阶段的算法,如 G1 中存在并发标记阶段,可将其整个算法视为并发算法。

狭义上的并发垃圾回收算法是以基础的标记-复制算法为基础,在各个阶段增加了并发操作实现的。与复制算法的3个阶段相对应,分为并发标记(mark)、并发转移(relocate)和并发重定位(remap):

1)并发标记

从 GC Roots 出发,使用遍历算法对对象的成员变量进行标记。同样的,并发标记也需要解决标记过程中引用关系变化导致的漏标记问题,这一点通过写屏障实现;

(2)并发转移

根据并发标记后的结果生成转移集合,把活跃对象转移(复制)到新的内存上,原来的内存空间可以回收,转移过程中会涉及到应用线程访问待转移对象的情况,一般的解决思路是加上读屏障,在完成转移任务后,再访问对象;

(3)并发重定位

对象转移后其内存地址发生了变化,所有指向对象老地址的指针都要修正到新的地址上,这一步一般通过读屏障来实现。

JVM堆空间划分

JVM首先采用分代算法将堆空间划分如下:

  1. 新生代
    • 新生代又划分为一个eden区两个大小相同的survivor区
  2. 老年代

默认情况下Java虚拟机采用的是动态分配策略,使用参数:XX:+UsePSAdaptiveSurvivorSizePolicy 据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。

也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。

img

通常来讲(除过逃逸分析是在栈上分配):new 一个对象需要在Eden区划分一片内存作为对象的存储空间。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。为了解决多个线程在同时创建时可能造成的占用内存冲突引入了TLAB(Thread Local Allocation Buffer)技术

具体来说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。

Minor GC

  1. 当 Eden 区的空间耗尽了怎么办?这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。

  2. 存活下来的对象,则会被送到 Survivor 区。前面提到,新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

  3. Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。

  4. 如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

  5. 当发生 Minor GC 时,我们应用了标记 - 复制算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。

    理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。

    这样一来,岂不是又做了一次全堆扫描呢?

卡表

HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。

  1. 该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。

  2. 这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

  3. 在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

  4. 由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

  5. 在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。

  6. 首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。

  7. 但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。

  8. 写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。

  9. 这么一来,写屏障便可精简为下面的伪代码[1]。这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。

    1
    CARD_TABLE [this address >> 9] = DIRTY;
  10. 虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。

  11. 不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题[2]。在介绍对象内存布局中我曾提到虚共享问题,讲的是几个 volatile 字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

  12. 在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

    1
    2
    3

    if (CARD_TABLE [this address >> 9] != DIRTY)
    CARD_TABLE [this address >> 9] = DIRTY;

垃圾回收器

1. Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。这个收集器是一个单线程工作的收集器,但它的“单线 程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

Serial/Serial Old收 集器的运行过程如下:

image-20220630213504099

2. ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

ParNew收集器的工作过程如图所示:

image-20220630213452053

3. Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器

Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

4. Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。这两点都将在后面的内容中继续讲解。

Serial Old收集器的工作过程如图所示。

image-20220630213437080

5. Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的。Parallel Old收集器的工作过程如图所示。

image-20220630213424387

6. CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

Concurrent Mark Sweep收集器运行过程如图:

image-20220630213407564

7. Garbage First(G1)收集器

G1是一款主要面向服务端应用的垃圾收集器,是目前垃圾回收器的前沿成果。HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK 5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。

G1收集器运行过程如图:

image-20220630213338954

8. ZGC

ZGC 特征

ZGC 收集器是一款基于 Region 内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

内存布局

ZGC 没有分代的概念

ZGC 的内存布局说起。与 Shenandoah 和 G1一样,ZGC 也采用基于 Region 的堆内存布局,但与它们不同的是 , ZGC 的 Region 具 有 动 态 性 (动态创建和销毁 , 以及动态的区域容量大小)。在 x64硬件平台下 , ZGC 的 Region 可以具有大、中、小三类容量(如下图所示):

  • 小型 Region (Small Region ):容量固定为 2M, 存放小于 256K 的对象。
  • 中型 Region (Medium Region):容量固定为 32M,放置大于等于256K但小于4M的对象。
  • 大型 Region (Large Region): 容量不固定,可以动态变化,但必须为2MB 的整数倍,用于放置 4MB或以上的大对象。
图片

参考链接:https://juejin.cn/post/7095643412082196511

参考链接

垃圾回收算法是如何设计的?

JVM 从入门到放弃之 ZGC 垃圾收集器

Comment and share

Java创建对象的5种方式

创建方式 原理
new关键字 通过调用构造器
反射 (同 new 关键字)
Object.clone 通过复制已有数据,来初始化新建对象实例
反序列化 (同 Object.clone)
Unsafe.allocateInstance 没有初始化实例字段

Java对构造器的约束:

  • 如果一个类没有任何构造器的话,会自动添加一个无参构造器。
  • 子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,也就是说 Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。
  • 显式调用又可分为两种,一是直接使用“super”关键字调用父类构造器,二是使用“this”关键字调用同一个类中的其他构造器。无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(不过这可以通过调用其他生成参数的方法,或者字节码注入来绕开。)
  • 通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。

对象内存占用分布

  1. 每个对象都有一个对象头(Object header)

    由标记字段(Mark Word)和类型指针所构成。其中:mark word用以存储Java虚拟机有关该对象的运行数据:hash码,GC信息以及锁信息,而类型指针则是指向该对象的类。

    1
    2
    3
    4
    5
    |--------------------------------------------------------------|
    | Object Header (128 bits) |
    |------------------------------------|-------------------------|
    | Mark Word (64 bits) | Klass pointer (64 bits) |
    |------------------------------------|-------------------------|
  2. 为了尽量较少对象的内存使用量,64位Java虚拟机引入压缩指针(-XX: +UseCompressedOops,默认开启),将Java对象指针压缩成为32位。

    1
    2
    3
    4
    5
    |--------------------------------------------------------------|
    | Object Header (96 bits) |
    |------------------------------------|-------------------------|
    | Mark Word (64 bits) | Klass pointer (32 bits) |
    |------------------------------------|-------------------------|
  3. 数组对象

    1
    2
    3
    4
    5
    |---------------------------------------------------------------------------------|
    | Object Header (128 bits) |
    |--------------------------------|-----------------------|------------------------|
    | Mark Word(64bits) | Klass pointer(32bits) | array length(32bits) |
    |--------------------------------|-----------------------|------------------------|

压缩指针

将堆中原本 64 位的 Java 对象指针压缩成 32 位的。

原理:

默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数(内存对齐-XX:ObjectAlignmentInBytes,默认值为 8)。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)

就算是关闭了压缩指针,Java 虚拟机还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。

字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的

字段重排列

字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1)

  1. 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。

    这里偏移量指的是字段地址与对象的起始地址差值。以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。

  2. 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。

    在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。

Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(false sharing)问题。这个注释也会影响到字段的排列。虚共享是怎么回事呢?

假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,注意使用虚拟机选项 -XX:-RestrictContended。如果你在 Java 9 以上版本试验的话,在使用 javac 编译时需要添加 –add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME

Comment and share

Java作为一种高级编程语言以其较高的可移植性被广泛使用在各个平台。那么它底层运行的原理是什么?

认识JRE和JDK

JRE(Java Runtime Environment):Java运行时环境。Java的所有执行都离不开JRE。JRE包含了Java虚拟机和Java核心类库等。

JDK(Java Development Kit):Java开发工具包。同样包含了JRE,并且还附带了一系列开发,诊断工具。

Java虚拟机具体是如何运行Java字节码的

  1. 执行Java代码首先要将它编译成class文件加载到Java虚拟机中。
  2. 加载后的Java类会被存放于方法区(Method Area)中。
  3. 实际运行时虚拟机会执行方法区中的代码。
  4. Java虚拟机会在内存中划分出堆和栈来存储运行时数据。
    • Java虚拟机会将栈细分为:
      1. 面向Java方法的Java方法栈(也叫Java虚拟机栈)。
      2. 面向本地方法的本地方法栈(用C++写的Native方法)。
      3. 以及存放各个线程执行位置的PC寄存器。
img
  1. 运行过程当中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用于存放局部变量以及字节码的操作数。这个栈帧大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间里连续分布。

  2. 退出执行方法时,不管是正常还是异常都会弹出当前线程栈帧,并舍弃。

  3. 硬件执行是需要Java虚拟机将字节码翻译成为机器码:

    1. 解释执行:逐条将字节码翻译成为机器码并执行。优势:无需等待编译。
    2. 即时编译(Just-In-Time Compilation, JIT):即将一个方法中包含的所有字节码编译成为机器码后再执行。优势:实际运行速度更快
    img

HotSpot采用的是混合模式:会对热点代码进行即时编译(JIT)。

Java虚拟机的运行效率

二八定律:即时编译建立在程序符合二八定律的假设上,即百分之二十的代码占据了百分之八十的计算资源。

对于大部分不常用的代码,无需耗费时间将其编译成机器码,而是采取解释执行。

对于近占据小部分的热点代码,可以将其编译成为机器码。

即时编译器:C1,C2和Graal(Java10引入)

C1编译器:又叫Client编译器,面向的是对启动性能有要求的客户端GUI程序,采用的优化手段较少。

C2编译器:又叫Server编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段较为复杂。编译时间长,但是生成的代码执行效率高。

Java7开始HotSpot默认采用的是分层编译:热点方法首先会被C1编译,而后热点中的热点会被C2进一步编译。

总结

  1. Java虚拟机Jvm:提供了可移植性:一次书写,处处运行。
  2. 代替我们管理了内存。
  3. 为了提高效率采用了混合编译。

Comment and share

Java执行流转图

img

Java8内存布局

Image

JVM内存与本地内存的区别

  • JVM内存

    • 受JVM内存大小限制,当大小超过参数设置的大小就会报OOM
  • 本地内存

    • 本地内存不受虚拟机内存参数限制,只受物理容量限制
    • 虽然不受参数限制,但是如果内存占用超过了物理内存大小,也会报OOM

Java运行时数据区域

Insert picture description here

  • 程序计数器(PC Registers)

    JVM Program counter 当前线程所执行的字节码的行号指示器,通过改变计数器的值,来选取下一行需要执行的指令。每个线程都有自己的程序计数器。所以程序计数器的结构如下:

    Insert picture description here
    • 程序计数器只占用很小的内存空间,所以可以被忽略。它也是存储最快的空间。
    • JVM规定每个线程都有自己的程序计数器,它的生命周期就是随着线程的执行周期。
    • 线程中任何时候都只有一个叫做当前方法。程序计数器里存储了当前线程正在执行的方法的指令地址。如果是本地方法正在执行则是undefined value。
    • 程序的分支,循环,跳转,异常处理都需要程序计数器控制。
    • 当字节码解释器工作时,它通过改变这个计数器的值来选择下一条要执行的字节码指令。
    • 程序计数器是对物理寄存器的抽象。
    • 它是 Java 虚拟机规范中唯一没有指定任何 outOtMemoryError 条件的区域。 (没有 GC,OOM)
    • 参考原文
  • 虚拟机栈(JVM Stacks)

    虚拟机栈是线程私有的,随线程毁灭,结构示意图:

    Image

每个方法被执行的时候,都会在虚拟机栈中同步创建一个栈帧(stack frame)

每个栈帧的包含: 局部变量表中存储着方法里的java基本数据类型, 局部变量表, 操作数栈, 动态连接, 方法返回地址

  • 线程创建入栈,线程结束出栈。

虚拟机栈可能会抛出两种异常:

- 如果线程请求的栈深度大于虚拟机所规定的栈深度,则会抛出StackOverFlowError即栈溢出
- 如果虚拟机的栈容量可以动态扩展,那么当虚拟机栈申请不到内存时会抛出OutOfMemoryError即OOM内存溢出
  • 本地方法栈

    • 线程私有执行native方法
  • Java堆

    • 对象实例
      • 类初始化生成的对象
      • 基本类型的数组也是对象实例
    • 字符串常量池
      • 字符串常量池原本存放于方法区,jdk7开始放置于堆中
      • 字符串常量池存储的是string对象的直接引用,而不是直接存放的对象,是一张string table
    • 静态变量
      • 静态变量是有static修饰的变量,jdk7时从方法区迁移至堆中
    • 线程分配缓冲区(Thread Local Allocation Buffer)
      • 线程私有,但是不影响java堆的共性
      • 增加线程分配缓冲区是为了提升对象分配时的效率

方法区(Method Area)

方法区是所有线程共享的内存,在java8以前是放在JVM内存中的,由永久代实现,受JVM内存大小参数的限制,在java8中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM参数的限制(当然,如果物理内存被占满了,方法区也会报OOM),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中,方法区与其他区域不同的地方在于,方法区在编译期间和类加载完成后的内容有少许不同,不过总的来说分为这两部分:

  • 类元信息

    • 类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)
    • 常量池表(Constant Pool Table)存储了类在编译期间生成的字面量、符号引用(什么是字面量?什么是符号引用?),这些信息在类加载完后会被解析到运行时常量池中
  • 运行时常量池(Runtime Constant Pool)

    • 运行时常量池主要存放在类加载后被解析的字面量与符号引用,但不止这些
    • 运行时常量池具备动态性,可以添加数据,比较多的使用就是String类的intern()方法

直接内存

在jdk1.4中加入了NIO(New Input/Putput)类,引入了一种基于通道(channel)与缓冲区(buffer)的新IO方式,它可以使用native函数直接分配堆外内存,然后通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以在一些场景下大大提高IO性能,避免了在java堆和native堆来回复制数据。

FAQ

类常量池、运行时常量池、字符串常量池有什么关系?有什么区别?

类常量池与运行时常量池都存储在方法区,而字符串常量池在jdk7时就已经从方法区迁移到了java堆中。

在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是类常量池,主要存放字面量和符号引用,而字面量的一部分便是文本字符,在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池;对于文本字符来说,它们会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池;字符串常量池存储的是字符串对象的引用,而不是字符串本身。

Comment and share

类加载器从应用程序和Java API加载类文件。只有运行中的程序实际需要Java API中的类的时候才会被加载到虚拟机。

img

加载

  • 加载是指查找字节流,并且据此创建类的过程。除了数组类没有对应的字节流是由JVM直接生成的。其他类,JVM则需要借助类加载器来完成查找字节流的过程。
img
  • 在JVM中,类的唯一性是由类加载器实例以及类的全名一同决定的。即便是同一串字节流,经由不同的类加载器加载,也会得到不同的类。在大型应用中我们往往借助这一特性,来运行同一个类的不同版本。

    启动类加载器(Bootstrap class loader)

  1. 由C++实现,没有对应的Java对象,因此在Java中只能用null来指代。
  2. 除了Bootstrap类加载器其他类加载器都是ClassLoader的子类,因此有对应的Java对象。这些类加载器都必须先由另外一个类加载器,比如启动类加载器加载至JVM当中,方能执行类加载。
  3. Java9之前启动类加载器只负责加载最为基础,最为重要的类:JRE的lib目录下的jar(以及虚拟机参数 -Xbootclasspath指定的类)。

扩展类加载器(Extension class loader)

  1. 扩展类加载器的父类加载器是启动类加载器。
  2. 负责加载相对次要、但又通用的类,比如存放在JRE的lib/ext目录下的jar以及由系统变量java.ext.dirs指定的类。

应用类加载器(Application class loader)

  1. 应用类加载器的父类加载器是扩展类加载器。
  2. 负责加载应用程序路径下的类。虚拟机参数 -cp/-classpath,系统变量 java.class.path或者环境变量CLASSPATH所指定的类。

Java 9引入了模块系统,并且略微更改了上述类加载器。扩展类加载器被更改为**平台类加载器(platform class loader)**。Java SE中除了少数几个关键模块,比如java.base是由启动类加载器加载之外,其他模块均由平台类加载器所加载。

自定义类加载器

除了JVM提供的类加载器外,可以自定义类加载器,通过继承ClassLoader类实现,主要重写findClass方法。实现特殊的加载方式。举例:对class文件进行加密,加载时再利用自定义类加载器对其进行解密。

双亲委派模型

在JVM中,如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给自己的父类加载器完成。每个类加载器都是如此,只有当自己的父类加载器在自己负责加载的范围内找不到指定类(ClassNotFoundException)时,子类才会进行自己去尝试加载。

作用:

  1. 提高安全性:防止覆盖系统类库中的类,提高安全性。
  2. 防止程序混乱:重复加载。
图片描述

链接

验证

确保被加载的类符合JVM规范。

准备

为被加载类中的静态字段分配内存。

解析

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

解析阶段的目的是将符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载的字段或者方法。那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化)。

初始化

类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;子类的初始化会触发父类的初始化;
  5. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  6. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  7. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China