洋蔥

贪婪,找不到比这更好的词了,是件好事。

[toc]

6. 堆

6.1. 堆(Heap)的核心概述

堆针对一个 JVM 进程来说是唯一的,也就是一个进程只有一个 JVM,但是进程包含多个线程,他们是共享同一堆空间的。

image-20200706195127740

一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。

Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了。是 JVM 管理的最大一块内存空间。

  • 堆内存的大小是可以调节的。

《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated

数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

堆,是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

image-20200706201904057

6.1.1. 堆内存细分

现代的垃圾收集器,大部分都是基于分代收集理论设计。

Java 7 及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区 Young/New 又被划分为 Eden 区和 Survivor 区
  • Tenure generation space 养老区 Old/Tenure
  • Permanent Space 永久区 Perm

Java 8 及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space 新生区 Young/New 又被划分为 Eden 区和 Survivor 区
  • Tenure generation space 养老区 Old/Tenure
  • Meta Space 元空间 Meta

约定:新生区(代)<=>年轻代 、 养老区<=>老年区(代)、 永久区<=>永久代

6.1.2. 堆空间内部结构(JDK7)

image-20200706203419496

6.1.3. 堆空间内部结构(JDK8)

image-20200706203835403

6.2. 设置堆内存大小与 OOM

6.2.1. 堆空间大小的设置

Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项”-Xmx”和”-Xms”来进行设置。

  • “-Xms”用于表示堆区的起始内存,等价于-XX:InitialHeapSize
  • “-Xmx”则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出 OutOfMemoryError 异常。

通常会将-Xms 和-Xmx 两个参数配置相同的值,其目的是为了能够在 ava 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下

  • 初始内存大小:物理电脑内存大小 / 64
  • 最大内存大小:物理电脑内存大小 / 4

6.2.2. OutOfMemory 举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OOMTest {
public static void main(String[]args){
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(20);
} catch (InterruptedException e){
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(1024*1024)));
}
}
}
class Picture{
private byte[] pixels;
public Picture(int length){
this.pixels = new byte[length];
}
}
1
2
3
Exception in thread "main" java.lang.OutofMemoryError: Java heap space
at com.atguigu. java.Picture.<init>(OOMTest. java:25)
at com.atguigu.java.O0MTest.main(OOMTest.java:16)

6.3. 年轻代与老年代

存储在 JVM 中的 Java 对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致

Java 堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)

其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)

image-20200707075847954

下面这参数开发中一般不会调:

image-20200707080154039

配置新生代与老年代在堆结构的占比。

  • 默认-XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3
  • 可以修改-XX:NewRatio=4,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5

在 HotSpot 中,Eden 空间和另外两个 survivor 空间缺省所占的比例是 8:1:1(实际测试是6:1:1)。

当然开发人员可以通过选项“-xx:SurvivorRatio”调整这个空间比例。比如-xx:SurvivorRatio=8

几乎所有的 Java 对象都是在 Eden 区被 new 出来的。绝大部分的 Java 对象的销毁都在新生代进行了。

  • IBM 公司的专门研究表明,新生代中 80% 的对象都是“朝生夕死”的。

可以使用选项”-Xmn“设置新生代最大内存大小,这个参数一般使用默认值就可以了,优先于 -XX:NewRatio

image-20210510105849497

6.4. 图解对象分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new 的对象先放伊甸园区。此区有大小限制。

  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区

  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区。

  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。

  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。

  6. 啥时候能去养老区呢?可以设置次数。默认是 15 次。

    • 可以设置参数:-Xx:MaxTenuringThreshold= N进行设置
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 GC:Major GC,进行养老区的内存清理

  8. 若养老区执行了 Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。

    1
    java.lang.OutofMemoryError: Java heap space

第08章_新生代对象分配与回收过程

流程图

image-20200707091058346

总结

  • 针对幸存者 s0,s1 区的总结:复制之后有交换,谁空谁是 to
  • 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收集

常用调优工具(在 JVM 下篇:性能监控与调优篇会详细介绍)

  • JDK 命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • Java Flight Recorder
  • GCViewer
  • GC Easy

6.5. Minor GC,MajorGC、Full GC

JVM 在进行 GC 时,并非每次都对上面三个内存区域(新生代、老年代;方法区)一起回收的,大部分时候回收的都是指新生代。

针对 Hotspot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC / Young GC):只是新生代(Eden,S0,S1)的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的圾收集。
      • 目前,只有 CMS GC 会有单独收集老年代的行为。
      • 注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):整个 java 堆和方法区的垃圾收集。

6.5.1. 最简单的分代式 GC 策略的触发条件

年轻代 GC(Minor GC)触发机制

  • 当年轻代空间不足时,就会触发 MinorGC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。(每次 Minor GC 会清理年轻代的内存。)

  • 因为Java 对象大多都具备朝生夕灭的特性.,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。

  • Minor GC 会引发 STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

image-20200707095606813

老年代 GC(Major GC / Full GC)触发机制

  • 指发生在老年代的 GC,对象从老年代消失时,我们说 “Major GC” 或 “Full GC” 发生了

  • 出现了 Major Gc,经常会伴随至少一次的 Minor GC(但非绝对的,在 Paralle1 Scavenge 收集器的收集策略里就有直接进行 MajorGC 的策略选择过程)

    • 也就是在老年代空间不足时,会先尝试触发 Minor Gc。如果之后空间还不足,则触发 Major GC
  • Major GC 的速度一般会比 Minor GC 慢 10 倍以上,STW 的时间更长

  • 如果 Major GC 后,内存还不足,就报 OOM 了

Full GC 触发机制(后面细讲):

触发 Full GC 执行的情况有如下五种:

  1. 调用 System.gc()时,会建议系统执行 Full GC,但是不必然执行
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  5. 由 Eden 区、survivor space0(From Space)区向 survivor space1(To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样暂停时间会短一些

6.6. 堆空间分代思想

为什么要把 Java 堆分代?不分代就不能正常工作了吗?

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有 Eden、两块大小相同的 survivor(又称为 from/to,s0/s1)构成,to 总为空。
  • 老年代:存放新生代中经历多次 GC 仍然存活的对象。

image-20200707101511025

其实不分代完全可以,分代的唯一理由就是优化 GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

image-20200707101543871

6.7. 内存分配策略

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 survivor 空间中,并将对象年龄设为 1。对象在 survivor 区中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁,其实每个 JVM、每个 GC 都有所不同)时,就会被晋升到老年代

对象晋升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到 Eden
  • 大对象直接分配到老年代(尽量避免程序中出现过多的大对象)
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:如果 survivor 区中相同年龄 a 的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于年龄 a 的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保: -XX:HandlePromotionFailure

6.8. 为对象分配内存:TLAB

6.8.1. 为什么有 TLAB(Thread Local Allocation Buffer)?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据

  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的

  • 为避免多个线程操作同一地址,需要使用加锁等机制,就会影响执行效率。

6.8.2. 什么是 TLAB?

  • 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。

  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

  • 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。

image-20210510114110526

6.8.3. TLAB 的再说明

  • 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但JVM 确实是将 TLAB 作为内存分配的首选

  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启 TLAB 空间。

  • 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 “-XX:TLABWasteTargetPercent” 设置 TLAB 空间所占用 Eden 空间的百分比大小。

  • 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

image-20200707104253530

6.9. 小结:堆空间的参数设置

官网地址:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 详细的参数内容会在JVM下篇:性能监控与调优篇中进行详细介绍,这里先熟悉下
-XX:+PrintFlagsInitial //查看所有的参数的默认初始值
-XX:+PrintFlagsFinal //查看所有的参数的最终值(可能会存在修改,不再是初始值)
//具体查看某个参数的命令工具:
jps // 查看当前运行中的进程有哪些
jinfo -flag SurvivorRatio 进程id // 查看指定进程id的SurvivorRatio参数值
-Xms //初始堆空间内存(默认为物理内存的1/64)
-Xmx //最大堆空间内存(默认为物理内存的1/4)
-Xmn //设置新生代的大小。(初始值及最大值)
-XX:NewRatio //配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio //设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold //设置新生代垃圾的最大年龄
-XX:+PrintGCDetails //输出详细的GC处理日志
//打印gc简要信息:①-Xx:+PrintGC ② - verbose:gc
-XX:HandlePromotionFalilure://是否设置空间分配担保

在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次 Minor GC 是安全的
  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的;
      • 如果小于,则改为进行一次 Full GC。
    • 如果HandlePromotionFailure=false,则改为进行一次 Full Gc。

在 JDK6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 openJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 FullGC。

6.X. 堆是分配对象的唯一选择么?

在《深入理解 Java 虚拟机》中关于 Java 堆内存有这样一段描述:

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配.。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

此外,前面提到的基于 OpenJDK 深度定制的 TaoBaoVM,其中创新的 GCIH(GC invisible heap)技术实现 off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。

6.X.1. 逃逸分析概述

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

举例 1

1
2
3
4
5
6
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除,每个栈里面包含了很多栈帧

1
2
3
4
5
6
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

上述方法如果想要StringBuffer sb不发生逃逸,可以这样写

1
2
3
4
5
6
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

举例 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class EscapeAnalysis {

public EscapeAnalysis obj;

/**
* 方法返回EscapeAnalysis对象,发生逃逸
* @return
*/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}

/**
* 为成员属性赋值,发生逃逸
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}

/**
* 对象的作用于仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}

/**
* 引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
}
}

参数设置

在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析

如果使用的是较早的版本,开发人员则可以通过:

  • 选项“-XX:+DoEscapeAnalysis“显式开启逃逸分析
  • 通过选项“-XX:+PrintEscapeAnalysis“查看逃逸分析的筛选结果

结论开发中能使用局部变量的,就不要使用在方法外定义。

6.X.2. 逃逸分析:代码优化

使用逃逸分析,编译器可以对代码做如下优化:

一、栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配

二、同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

三、分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。

栈上分配

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

常见的栈上分配的场景

在逃逸分析中,已经说明了。发生逃逸的情况包括 给成员变量赋值、方法返回值、实例引用传递。没有发生逃逸才可以实现栈上分配。

同步省略

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

举例

1
2
3
4
5
6
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}

代码中对 hellis 这个对象加锁,但是 hellis 对象的生命周期只在 f()方法中,并不会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉,优化成:

1
2
3
4
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}

标量替换

标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

举例

1
2
3
4
5
6
7
8
9
10
11
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}

以上代码,经过标量替换后,就会变成

1
2
3
4
5
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}

可以看到,Point 这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。

标量替换参数设置

参数-XX:EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配到栈上。

上述代码在主函数中进行了 1 亿次 alloc。调用进行对象创建,由于 User 对象实例需要占据约 16 字节的空间,因此累计分配空间达到将近 1.5GB。如果堆空间小于这个值,就必然会发生 GC。使用如下参数运行上述代码:

1
-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations

这里设置参数如下:

  • 参数-server:启动 Server 模式,因为在 server 模式下,才可以启用逃逸分析。
  • 参数-XX:+DoEscapeAnalysis:启用逃逸分析
  • 参数-Xmx10m:指定了堆空间最大为 10MB
  • 参数-XX:+PrintGC:将打印 Gc 日志
  • 参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有 id 和 name 两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配

6.X.3. 逃逸分析小结:逃逸分析并不成熟

关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK1.6 才有实现,而且这项技术到如今也并不是十分成熟。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段

注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上

目前很多书籍还是基于 JDK7 以前的版本,JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

本章小结

年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。

老年代放置长生命周期的对象,通常都是从 survivor 区域筛选拷贝过来的 Java 对象。当然,也有特殊情况,我们知道普通的对象会被分配在 TLAB 上;如果对象较大,JVM 会试图直接分配在 Eden 其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。当 GC 只发生在年轻代中,回收年轻代对象的行为被称为 MinorGc。

当 GC 发生在老年代时则被称为 MajorGc 或者 FullGC。一般的,MinorGc 的发生频率要比 MajorGC 高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

[toc]

5. 本地方法接口和本地方法栈

5.1. 什么是本地方法?

简单地讲,一个 Native Method 是一个 Java 调用非 Java 代码的接囗。一个 Native Method 是这样一个 Java 方法:该方法的实现由非 Java 语言实现,比如 C。这个特征并非 Java 所特有,很多其它的编程语言都有这一机制,比如在 C++中,你可以用 extern “c” 告知 c++编译器去调用一个 c 的函数。

A native method is a Java method whose implementation is provided by non-java code.

在定义一个 native method 时,并不提供实现体(有些像定义一个 Java interface),因为其实现体是由非 java 语言在外面实现的。

本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序。

image-20200706164139252

举例

1
2
3
4
5
6
public class IHaveNatives{
public native void methodNative1(int x);
public native static long methodNative2();
private native synchronized float methodNative3(Object o);
native void methodNative4(int[] ary) throws Exception;
}

标识符 native 可以与其它 java 标识符连用,但是 abstract 除外

5.2. 为什么使用 Native Method?

Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

与 Java 环境的交互

有时 Java 应用需要与 Java 外面的环境交互,这是本地方法存在的主要原因。你可以想想 Java 需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解 Java 应用之外的繁琐的细节。

与操作系统的交互

JVM 支持着 Java 语言本身和运行时库,它是 Java 程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用 Java 实现了 jre 的与底层系统的交互,甚至 JVM 的一些部分就是用 c 写的。还有,如果我们要使用一些 Java 语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

Sun’s Java

Sun 的解释器是用 C 实现的,这使得它能像一些普通的 C 一样与外部交互。jre 大部分是用 Java 实现的,它也通过一些本地方法与外界交互。例如:类 java.lang.Thread 的 setPriority()方法是用 Java 实现的,但是它实现调用的是该类里的本地方法 setPriority()。这个本地方法是用 C 实现的,并被植入 JVM 内部,在 Windows 95 的平台上,这个本地方法最终将调用 Win32 setPriority() ApI。这是一个本地方法的具体实现由 JVM 直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被 JVw 调用。

现状

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过 Java 程序驱动打印机或者 Java 系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用 Socket 通信,也可以使用 Web Service 等等,不多做介绍。

5.2. 本地方法栈

Java 虚拟机栈于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。

本地方法是使用 C 语言实现的。

它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。

image-20200706174708418

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存。

并不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈。

在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。

[toc]

4. 虚拟机栈

4.1. 虚拟机栈概述

4.1.1. 虚拟机栈出现的背景

由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。

优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令

4.1.2. 初步印象

有不少 Java 开发人员一提到 Java 内存结构,就会非常粗粒度地将 JVM 中的内存区理解为仅有 Java 堆(heap)和 Java 栈(stack)?为什么?

4.1.3. 内存中的栈与堆

栈是运行时的单位,而堆是存储的单位

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
  • 堆解决的是数据存储的问题,即数据怎么放,放哪里

image-20200705163928652

4.1.4. 虚拟机栈基本内容

Java 虚拟机栈是什么?

Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用,是线程私有的。

生命周期

生命周期和线程一致

作用

主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

栈的特点

栈是一种快速有效的分配存储方式,访问速度仅次于罹序计数器。

JVM 直接对 Java 栈的操作只有两个:

  • 每个方法执行,伴随着进栈(入栈、压栈)
  • 执行结束后的出栈工作

对于栈来说不存在垃圾回收问题(栈存在溢出的情况)

image-20200705165025382

面试题:开发中遇到哪些异常?

栈中可能出现的异常

Java 虚拟机规范允许Java 栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的 Java 虚拟机栈,那每一个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个StackOverflowError 异常。

  • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。

1
2
3
4
5
6
7
8
public static void main(String[] args) {
test();
}
public static void test() {
test();
}
//抛出异常:Exception in thread"main"java.lang.StackoverflowError
//程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。

设置栈内存大小

我们可以使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StackDeepTest{
private static int count=0;
public static void recursion(){
count++;
recursion();
}
public static void main(String args[]){
try{
recursion();
} catch (Throwable e){
System.out.println("deep of calling="+count);
e.printstackTrace();
}
}
}

4.2. 栈的存储单位

4.2.1. 栈中存储什么?

每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在

在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

4.2.2. 栈运行原理

JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则

在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)

执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

image-20200705203142545

不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出

1
2
3
4
5
6
7
8
9
public class CurrentFrameTest{
public void methodA(){
system.out.println("当前栈帧对应的方法->methodA");
methodB();
system.out.println("当前栈帧对应的方法->methodA");
}
public void methodB(){
System.out.println("当前栈帧对应的方法->methodB");
}

4.2.3. 栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(operand Stack)(或表达式栈)
  • 动态链接(DynamicLinking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

image-20200705204836977

并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的

image-20200705205443993

4.3. 局部变量表(Local Variables)

局部变量表也被称之为局部变量数组或本地变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及 returnAddress 类型。

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

4.3.1. 关于 Slot 的理解

  • 局部变量表,最基本的存储单元是 Slot(变量槽)

  • 参数值的存放总是在局部变量数组的 index0 开始,到数组长度-1 的索引结束。

  • 局部变量表中存放编译期可知的各种基本数据类型(8 种),引用类型(reference),returnAddress 类型的变量。

  • 在局部变量表里,32 位以内的类型只占用一个 slot(包括 returnAddress 类型),64 位的类型(long 和 double)占用两个 slot。

  • byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0 表示 false,非 0 表示 true。

  • JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上

  • 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 doub1e 类型变量)

  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。

image-20200705212454445

4.3.2. Slot 的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SlotTest {
public void localVarl() {
int a = 0;
System.out.println(a);
int b = 0;
}
public void localVar2() {
{
int a = 0;
System.out.println(a);
}
//此时的就会复用a的槽位
int b = 0;
}
}

4.3.3. 静态变量与局部变量的对比

参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。

和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

1
2
3
4
public void test(){
int i;
System. out. println(i);
}

这样的代码是错误的,没有赋值不能够使用。

4.3.4. 补充说明

在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

4.4. 操作数栈(Operand Stack)

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
  • 比如:执行复制、交换、求和等操作

image-20200706090618332

代码举例

1
2
3
4
5
public void testAddOperation(){
byte i = 15;
int j = 8;
int k = i + j;
}

字节码指令信息

1
2
3
4
5
6
7
8
9
10
11
public void testAddOperation();
Code:
0: bipush 15
2: istore_1
3: bipush 8
5: istore_2
6:iload_1
7:iload_2
8:iadd
9:istore_3
10:return

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性中,为 max_stack 的值。

栈中的任何一个元素都是可以任意的 Java 数据类型

  • 32bit 的类型占用一个栈单位深度
  • 64bit 的类型占用两个栈单位深度

操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

4.5. 代码追踪

1
2
3
4
5
public void testAddOperation() {
byte i = 15;
int j = 8;
int k = i + j;
}

使用 javap 命令反编译 class 文件: javap -v 类名.class

1
2
3
4
5
6
7
8
9
10
11
public void testAddoperation();
Code:
0: bipush 15
2: istore_1
3: bipush 8
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return

image-20200706093131621

image-20200706093251302

image-20200706093646406

image-20200706093751711

image-20200706093859191

image-20200706093921573

image-20200706094046782

image-20200706094109629

程序员面试过程中,常见的 i++和++i 的区别,放到字节码篇章时再介绍。

4.6. 栈顶缓存技术(Top Of Stack Cashing)技术

前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

4.7. 动态链接(Dynamic Linking)

动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic 指令

在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 class 文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

image-20200706101251847

为什么需要运行时常量池呢?

常量池的作用:就是为了提供一些符号和常量,便于指令的识别

4.8. 方法的调用:解析与分配

在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

4.8.1. 静态链接

当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接

4.8.2. 动态链接

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。

静态链接和动态链接不是名词,而是动词,这是理解的关键。


对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

4.8.3. 早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

4.8.4. 晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。


随着高级语言的横空出世,类似于 Java 一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。

Java 中任何一个普通的方法其实都具备虚函数的特征,它们相当于 C++语言中的虚函数(C++中则需要使用关键字 virtual 来显式定义)。如果在 Java 程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字 final 来标记这个方法。


4.8.5. 虚方法和非虚方法

如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。

静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法。

在类加载的解析阶段就可以进行解析,如下是非虚方法举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Father {

public static void print(String str) {
System.out.println("father " + str);
}

private void show(String str) {
System.out.println("father" + str);
}
}

class Son extends Father {
public class VirtualMethodTest {
public static void main(String[] args) {
Son.print("coder");
// Father fa=new Father();
//fa.show("atguigu.com");
}
}
}

虚拟机中提供了以下几条方法调用指令:

普通调用指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法,虚方法。

动态调用指令:

  • invokedynamic:动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而 invokedynamic 指令则支持由用户确定方法版本。其中 invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(fina1 修饰的除外)称为虚方法。

关于 invokednamic 指令

  • JVM 字节码指令集一直比较稳定,一直到 Java7 中才增加了一个 invokedynamic 指令,这是Java 为了实现「动态类型语言」支持而做的一种改进。

  • 但是在 Java7 中并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令。直到 Java8 的 Lambda 表达式的出现,invokedynamic 指令的生成,在 Java 中才有了直接的生成方式。

  • Java7 中增加的动态语言类型支持的本质是对 Java 虚拟机规范的修改,而不是对 Java 语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在 Java 平台的动态语言的编译器。

动态类型语言和静态类型语言

动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。

说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

4.8.6. 方法重写的本质

Java 语言中方法重写的本质:

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
  2. 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出 java.1ang.AbstractMethodsrror 异常。

IllegalAccessError 介绍

程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

4.8.7. 方法的调用:虚方法表

在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM 采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。

每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

虚方法表是什么时候被创建的呢?

虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。

举例 1:

image-20200706144954070

举例 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
interface Friendly {
void sayHello();

void sayGoodbye();
}

class Dog {
public void sayHello() {
}

public String tostring() {
return "Dog";
}
}

class Cat implements Friendly {
public void eat() {
}

public void sayHello() {
}

public void sayGoodbye() {
}

protected void finalize() {
}

public String tostring() {
return "Cat";
}
}

class CockerSpaniel extends Dog implements Friendly {
public void sayHello() {
super.sayHello();
}

public void sayGoodbye() {
}
}

image-20210509203351535

4.9. 方法返回地址(return address)

存放调用该方法的 pc 寄存器的值。一个方法的结束,有两种方式:

  • 正常执行完成
  • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 pc 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
    • 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
    • 在字节码指令中,返回指令包含 ireturn(当返回值是 boolean,byte,char,short 和 int 类型时使用),lreturn(Long 类型),freturn(Float 类型),dreturn(Double 类型),areturn。另外还有一个 return 指令声明为 void 的方法,实例初始化方法,类和接口的初始化方法使用。
  2. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口

方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码

1
Exception table:from to target type4	 16	  19   any19	 21	  19   any

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置 PC 寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

4.10. 一些附加信息

栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

4.11. 栈的相关面试题

  • 举例栈溢出的情况?(StackOverflowError)
    • 通过 -Xss 设置栈的大小
  • 调整栈大小,就能保证不出现溢出么?
    • 不能保证不溢出
  • 分配的栈内存越大越好么?
    • 不是,一定时间内降低了 OOM 概率,但是会挤占其它的线程空间,因为整个空间是有限的。
  • 垃圾回收是否涉及到虚拟机栈?
    • 不会
  • 方法中定义的局部变量是否线程安全?
    • 具体问题具体分析。如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
运行时数据区 是否存在 Error 是否存在 GC
程序计数器
虚拟机栈 是(SOE)
本地方法栈
方法区 是(OOM)

[toc]

3. 运行时数据区及程序计数器

3.1. 运行时数据区

3.1.1. 概述

本节主要讲的是运行时数据区,也就是下图这部分,它是在类加载完成后的阶段

image-20200705111640511

当我们通过前面的:类的加载-> 验证 -> 准备 -> 解析 -> 初始化 这几个阶段完成后,就会用到执行引擎对我们的类进行使用,同时执行引擎将会使用到我们运行时数据区

image-20200705111843003

内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行 JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。结合 JVM 虚拟机规范,来探讨一下经典的 JVM 内存布局。

image-20210509174724223

我们把大厨后面的东西(切好的菜,刀,调料),比作是运行时数据区。而厨师可以类比于执行引擎,将通过准备的东西进行制作成精美的菜品

image-20210509174543026

我们通过磁盘或者网络 IO 得到的数据,都需要先加载到内存中,然后 CPU 从内存中获取数据进行读取,也就是说内存充当了 CPU 和磁盘之间的桥梁

image-20200705112416101

Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。

灰色的为单独线程私有的,红色的为多个线程共享的。即:

  • 每个线程:独立包括程序计数器、栈、本地栈。
  • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)

image-20200705112601211

每个 JVM 只有一个 Runtime 实例。即为运行时环境,相当于内存结构的中间的那个框框:运行时环境。

image-20210509173410373

3.1.2. 线程

线程是一个程序里的运行单元。JVM 允许一个应用有多个线程并行的执行。 在 Hotspot JVM 里,每个线程都与操作系统的本地线程直接映射。

当一个 Java 线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java 线程执行终止后,本地线程也会回收。

操作系统负责所有线程的安排调度到任何一个可用的 CPU 上。一旦本地线程初始化成功,它就会调用 Java 线程中的 run()方法。

3.1.3. JVM 系统线程

如果你使用 console 或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main(String[] args)的 main 线程以及所有这个 main 线程自己创建的线程。

这些主要的后台系统线程在 Hotspot JVM 里主要是以下几个:

  • 虚拟机线程:这种线程的操作是需要 JVM 达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要 JVM 达到安全点,这样堆才不会变化。这种线程的执行类型包括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
  • GC 线程:这种线程对在 JVM 里不同种类的垃圾收集行为提供了支持。
  • 编译线程:这种线程在运行时会将字节码编译成到本地代码。
  • 信号调度线程:这种线程接收信号并发送给 JVM,在它内部通过调用适当的方法进行处理。

3.2. 程序计数器(PC 寄存器)

JVM 中的程序计数寄存器(Program Counter Register)中,Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的现场信息。CPU 只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为 PC 计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟

image-20200705155551919

作用

PC 寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

image-20200705155728557

它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域

在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行 native 方法,则是未指定值(undefined)。

它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

它是唯一一个在 Java 虚拟机规范中没有规定任何 OutofMemoryError 情况的区域。

举例说明

1
2
3
4
5
public int minus(){
intc = 3;
intd = 4;
return c - d;
}

字节码文件:

1
2
3
4
5
6
7
8
0: iconst_3
1: istore_1
2: iconst_4
3: istore_2
4: iload_1
5: iload_2
6: isub
7: ireturn

使用 PC 寄存器存储字节码指令地址有什么用呢?为什么使用 PC 寄存器记录当前线程的执行地址呢?

因为 CPU 需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

JVM 的字节码解释器就需要通过改变 PC 寄存器的值来明确下一条应该执行什么样的字节码指令。

image-20200705161409533

PC 寄存器为什么被设定为私有的?

我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU 会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

CPU 时间片

CPU 时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。

在宏观上:俄们可以同时打开多个应用程序,每个程序并行不悖,同时运行。

但在微观上:由于只有一个 CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。

image-20200705161849557

[toc]

2. 类加载子系统

2.1. 内存结构概述

  • Class 文件
  • 类加载子系统
  • 运行时数据区
    • 方法区
    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  • 执行引擎
  • 本地方法接口
  • 本地方法库

image-20200705080719531

image-20200705080911284

如果自己想手写一个 Java 虚拟机的话,主要考虑哪些结构呢?

  • 类加载器
  • 执行引擎

2.2. 类加载器与类的加载过程

类加载器子系统作用

image-20200705081813409

  • 类加载器子系统负责从文件系统或者网络中加载 Class 文件,class 文件在文件开头有特定的文件标识。
  • ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。
  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 Class 文件中常量池部分的内存映射)

类加载器 ClasLoader 角色

image-20200705081913538

  • class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到 JVM 当中来根据这个文件实例化出 n 个一模一样的实例。
  • class file 加载到 JVM 中,被称为 DNA 元数据模板,放在方法区。
  • 在.class 文件->JVM->最终成为元数据模板,此过程就要一个运输工具(类装载器 Class Loader),扮演一个快递员的角色。

类的加载过程

1
2
3
4
5
6
7
8
/**
*示例代码
*/
public class HelloLoader {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

用流程图表示上述示例代码:

image-20200705082255746

加载阶段

image-20200705082601441

    1. 通过一个类的全限定名获取定义此类的二进制字节流
    1. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    1. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

补充:加载 class 文件的方式

  • 本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从 zip压缩包中读取,成为日后 jar、war 格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术
  • 由其他文件生成,典型场景:JSP 应用
  • 从专有数据库中提取.class 文件,比较少见
  • 加密文件中获取,典型的防 Class 文件被反编译的保护措施

链接阶段

  • 验证(Verify)
    • 目的在子确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
    • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
  • 准备(Prepare)
    • 为类变量分配内存并且设置该类变量的默认初始值,即零值。
    • 这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显式初始化;
    • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
  • 解析(Resolve)
    • 将常量池内的符号引用转换为直接引用的过程。
    • 事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
    • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java 虚拟机规范》的 Class 文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等。

初始化阶段

  • 初始化阶段就是执行类构造器方法<clinit>()的过程。
  • 此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
  • 构造器方法中指令按语句在源文件中出现的顺序执行。
  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())
  • 若该类具有父类,JVM 会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。
  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

2.3. 类加载器分类

JVM 支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)自定义类加载器(User-Defined ClassLoader)

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有 3 个,如下所示:

image-20200705094149223

这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。

2.3.1. 虚拟机自带的加载器

启动类加载器(引导类加载器,Bootstrap ClassLoader)

  • 这个类加载使用 C/C++语言实现的,嵌套在 JVM 内部。
  • 它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类
  • 并不继承自 ava.lang.ClassLoader,没有父加载器。
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
  • 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类

扩展类加载器(Extension ClassLoader)

  • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现。
  • 派生于 ClassLoader 类
  • 父类加载器为启动类加载器
  • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/1ib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。

应用程序类加载器(系统类加载器,AppClassLoader)

  • java 语言编写,由 sun.misc.LaunchersAppClassLoader 实现
  • 派生于 ClassLoader 类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
  • 通过 ClassLoader#getSystemclassLoader() 方法可以获取到该类加载器

2.3.2. 用户自定义类加载器

在 Java 的日常应用程序开发中,类的加载几乎是由上述 3 种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。 为什么要自定义类加载器?

  • 隔离加载类
  • 修改类加载的方式
  • 扩展加载源
  • 防止源码泄漏

用户自定义类加载器实现步骤:

  1. 开发人员可以通过继承抽象类 ava.lang.ClassLoader 类的方式,实现自己的类加载器,以满足一些特殊的需求
  2. 在 JDK1.2 之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写 loadClass() 方法,从而实现自定义的类加载类,但是在 JDK1.2 之后已不再建议用户去覆盖 loadclass() 方法,而是建议把自定义的类加载逻辑写在 findClass()方法中
  3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLClassLoader 类,这样就可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

2.4. ClassLoader 的使用说明

ClassLoader 类是一个抽象类,其后所有的类加载器都继承自 ClassLoader(不包括启动类加载器)

image-20200705103516138

sun.misc.Launcher 它是一个 java 虚拟机的入口应用

image-20200705103636003

获取 ClassLoader 的途径

  • 方式一:获取当前 ClassLoader

    1
    clazz.getClassLoader()
  • 方式二:获取当前线程上下文的 ClassLoader

    1
    Thread.currentThread().getContextClassLoader()
  • 方式三:获取系统的 ClassLoader

    1
    ClassLoader.getSystemClassLoader()
  • 方式四:获取调用者的 ClassLoader

    1
    DriverManager.getCallerClassLoader()

2.5. 双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

工作原理

  • 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20200705105151258

举例

当我们加载 jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar 是基于 SPI 接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI 核心类,然后在加载 SPI 接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类 jdbc.jar 的加载。

image-20200705105810107

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心 API 被随意篡改
    • 自定义类:java.lang.String
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)

沙箱安全机制

自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。

2.6. 其他

如何判断两个 class 对象是否相同

在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名。
  • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。

换句话说,在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。

对类加载器的引用

JVM 必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM 需要保证这两个类型的类加载器是相同的。

类的主动使用和被动使用

Java 程序对类的使用方式分为:主动使用和被动使用。

主动使用,又分为七种情况:

  • 创建类的实例

  • 访问某个类或接口的静态变量,或者对该静态变量赋值

  • 调用类的静态方法

  • 反射(比如:Class.forName(”com.atguigu.Test”))

  • 初始化一个类的子类

  • Java 虚拟机启动时被标明为启动类的类

  • JDK 7 开始提供的动态语言支持:

    java.lang.invoke.MethodHandle 实例的解析结果

    REF_getStatic、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用 Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化

推荐链接:

菜鸟 Linux 磁盘管理

Linux磁盘管理—-分区格式化挂载fdisk、mkfs、mount

相关命令:

菜鸟 df 命令df 命令df (Unix) - 维基百科

菜鸟 du 命令du 命令du (Unix) - 维基百科

菜鸟 fdisk 命令fdisk 命令 —————————— parted 命令GNU Parted - 维基百科

菜鸟 mkfs 命令mkfs 命令

菜鸟 mount 命令mount 命令mount (Unix) - 维基百科 —> 临时挂载

菜鸟 umount命令umount 命令

菜鸟 fsck 命令fsck 命令fsck - 维基百科

lsblk 命令

partprobe 命令

resize2fs 命令

阅读全文 »
0%