OpenJDK源代码:http://hg.openjdk.java.net/jdk/jdk11
JVM 体系结构
JVM 的结构基本上由 4 部分组成:
-
类加载器,在 JVM 启动时或者类运行时将需要的 class 加载到 JVM 中
-
执行引擎,执行引擎的任务是负责执行 class 文件中包含的字节码指令,相当于实际机器上的 CPU
-
内存区,将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者 PC 指针的记录器等
-
本地方法调用,调用 C 或 C++ 实现的本地方法的代码返回结果
JAVA 内存区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间。
① 线程共享区域
-
Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。Java 堆所使用的内存不需要保证是连续的
-
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
很多人习惯的把方法区称为永久代(Permanent Generation),但实际上这两者并不等价。通俗来说,方法区是一种规范,而永久代是 HotSpot 虚拟机实现这个规范的一种手段,对于其他虚拟机(比如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。
HotSpot 虚拟机在 JDK 8 中完全废弃了永久代的概念,改用在本地内存中实现的 元空间(Meta-space),将常量池和类静态变量放入java堆中。元空间的最大可分配空间就是系统可用内存空间。
这项改动是很有必要的,原因有:
-
为永久代设置空间大小是很难确定的。 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。 “Exception in thread’ dubbo client x.x connector’java.lang.OutOfMemoryError: PermGenspace” 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
-
对永久代进行调优是很困难的。
-
内存分配策略
Ⅰ 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
Ⅱ 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold
,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
Ⅲ 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold
用来定义年龄的阈值。
Ⅳ 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
Ⅴ 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
② 线程私有区域
-
**虚拟机栈(Java Virtual Machine Stacks)**其实是由一个一个的 栈帧(Stack Frame) 组成的,一个栈帧描述的就是一个 Java 方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法的返回地址等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程
-
**本地方法栈(Native Method Stack)**和上面我们所说的虚拟机栈作用基本一样,区别只不过是本地方法栈为虚拟机使用到的 Native 方法服务,而虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务。
Native 方法:“A native method is a Java method whose implementation is provided by non-java code.”
一个 Native 方法其实就是一个接口,但是它的具体实现是在外部由非 Java 语言写的。所以同一个 Native 方法,如果用不同的虚拟机去调用它,那么得到的结果和运行效率可能是不一样的,因为不同的虚拟机对于某个 Native 方法都有自己的实现,比如 Object 类的
hashCode
方法。这使得 Java 程序能够超越 Java 运行时的界限,有效地扩充了 JVM。
-
**程序计数器(Program Counter Register)**是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
由于 Java 虚拟机的多线程是通过轮流分配 CPU 时间片的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
如果线程正在执行的是一个 Java 方法,程序计数器中记录的就是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined).
直接内存
也叫堆外内存,并不是jvm运行时数据区的一部分,但在并发编程中频繁使用(NIO,Netty,Flink,HBase,Hadoop等)。
通过堆外内存可以避免数据在java堆和native堆中来回复制。
简单的代码例子
一个简单的学生类:
public class Student{
public String name;
public Student(String name){
this.name = name;
}
public void sayName(){
System.out.println("student's name is " + name);
}
}
一个 main
方法:
public class App {
public static void main(String[] args){
Student student = new Student("Jack");
student.sayName();
}
}
⭐ 执行 main
方法的步骤如下:
- 编译好 App.java 得到 App.class 后,执行 App.class,系统会启动一个 JVM 进程,从 classpath 路径中找到一个名为 App.class 的二进制文件,将 App 的类信息加载到运行时数据区的方法区内,这个过程叫做 App 类的加载
- JVM 找到 App 的主程序入口,执行
main
方法 - 这个
main
中的第一条语句为Student student = new Student("tellUrDream")
,就是让 JVM 创建一个Student
对象,但是这个时候方法区中是没有Student
类的信息的,所以 JVM 马上加载Student
类,把Student
类的信息放到方法区中 - 加载完
Student
类后,JVM 在堆中为一个新的Student
实例分配内存,然后调用构造函数初始化Student
实例,这个Student
实例持有 指向方法区中的Student
类的类型信息 的引用 - 执行
student.sayName();
时,JVM 根据student
的引用找到student
对象,然后根据student
对象持有的引用定位到方法区中student
类的类型信息的方法表,获得sayName()
的字节码地址。 - 执行
sayName()
GC 垃圾收集
垃圾收集(Garbage Collection ,GC)需要思考的 3 件事情:哪些内存需要回收,什么时候回收,如何回收
对象是否可被回收
① 引用计数算法(Reference Counting)
为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
② 可达性分析算法(Reachability Analysis)
以 GC Roots
为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
可作为 GC Roots
的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
③ 引用类型
Ⅰ 强引用
被强引用关联的对象不会被回收。
使用 new
一个新对象的方式来创建强引用:
Object obj = new Object();
Ⅱ 软引用
被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference
类来创建软引用:
Ⅲ 弱引用
被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
使用 WeakReference
类来创建弱引用:
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
Ⅳ 虚引用
又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
使用 PhantomReference
来创建虚引用:
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;
④ finalize()
需要注意的是:finalize
并不是 C++ 中的析构函数, 该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。try-finally
等方式可以做得更好。
⑤ 回收方法区
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
- 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
垃圾收集算法
① 标记-清除算法(Mark-Sweep)
分为两个阶段:标记阶段,清除阶段
-
在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
-
在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
② 复制算法(Copying)
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足是只使用了内存的一半。
现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
③ 标记-整理算法(Mark-Compact)
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
优点:
- 不会产生内存碎片
不足:
- 需要移动大量对象,处理效率比较低。
④ 分代收集算法(Generational Collection)
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代使用:复制算法
- 老年代使用:标记 - 清除 或者 标记 - 整理 算法
垃圾收集器
这里讨论的收集器基于 JDK 1.7 Update 14 之后的 HotSpot 虚拟机,这个虚拟机包含的所有收集器如下所示:
连线表示垃圾收集器可以配合使用。
- 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
- 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
① Serial 收集器:单线程,复制
Serial 翻译为串行,也就是说它以串行的方式执行。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
它的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。
② ParNew 收集器:多线程,复制
它是 Serial 收集器的多线程版本。
它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。
③ Parallel Scavenge 收集器:多线程,复制
与 ParNew 一样是多线程收集器。
其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
④ Serial Old 收集器:单线程,标记整理
是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
⑤ Parallel Old 收集器:多线程,标记整理
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
⑥ CMS 收集器(Concurrent Mark Sweep):多线程,标记清除
Mark Sweep 指的是标记 - 清除算法。该收集器是以获取最短回收停顿时间为目标的收集器
分为以下四个流程:
- 初始标记(initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记(concurrent mark):进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记(remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除(concurrent sweep):不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
😓 具有以下缺点:
- 吞吐量低:并发设计占用了一部分线程,导致应用程序变慢,降低吞吐量,导致实际 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
⑦ G1 收集器(Garbage-First):多线程,标记整理
它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
具备如下特点:
- 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
内存分配与回收策略
Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
Major GC: 回收老年代,目前只有CMS会单独回收老年代
Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
Minor GC
采用复制算法
- 把Eden和ServivorFrom区的存活对象复制到ServivorTo区。如果对象年龄达到标准 / ServivorTo内存不够 / 大对象,则复制到老年代。
- 清空Eden和ServivorFrom。
- 将ServivorFrom和ServivorTo互换。
Major GC
Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
Ⅰ 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
Ⅱ 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn
虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
Ⅲ 空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
Ⅳ JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
Ⅴ Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure
错误,并触发 Full GC。
JVM 多线程实现
JVM中的线程与操作系统中的线程相互对应。
在JVM的准备工作都完成时,会调用操作系统的接口创建一个原生线程。原生线程初始化完毕时,会调用java线程的run()执行线程。期间,操作系统负责调度所有线程。
在JVM后台运行的线程主要有:
- 虚拟机线程:会在JVM到达 SafePoint 时出现
- 周期性任务线程:用来执行周期性操作
- GC 线程:支持垃圾回收
- 编译器线程:在运行时将字节码动态编译成本地平台机器码,实现JVM跨平台
- 信号分发线程:接收发送到JVM的信号并调用JVM方法
类文件结构
Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class
文件最终运行在 Java 虚拟机之上。
可以说.class
文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。
根据 Java 虚拟机规范,类文件由单个 ClassFile 结构组成:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//Class 文件的字段属性
field_info fields[fields_count];//一个类会可以有个字段
u2 methods_count;//Class 文件的方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
💡
u4
表示 4 个字节,u2
表示 2 个字节
Class 文件字节码结构组织示意图 :
下面详细介绍一下 Class 文件结构涉及到的一些组件 👇
① 魔数
u4 magic; //Class 文件的标志
每个 Class 文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。
程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其它特殊的含义。
② Class 文件版本
u2 minor_version; //Class 的小版本号
u2 major_version; //Class 的大版本号
紧接着魔数的四个字节存储的是 Class 文件的版本号:第五和第六字节是次(小)版本号,第七和第八字节是主(大)版本号。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。
③ 常量池
u2 constant_pool_count; //常量池的数量
cp_info constant_pool[constant_pool_count-1]; //常量池
紧接着主次版本号之后的是常量池,常量池的数量是 constant_pool_count - 1
(常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)。
常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final
的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型(1 个字节)的标志位 tag
来标识常量的类型,代表当前这个常量属于哪种常量类型.
类型 | 标志(tag) | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整形字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MothodType_info | 16 | 标志方法类型 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
.class
文件可以通过javap -v class类名
指令来看一下其常量池中的信息 (javap -v class类名-> temp.txt
:将结果输出到 temp.txt 文件)。
④ 访问标志
u2 access_flags; // Class 的访问标记
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public
或者 abstract
类型,如果是类的话是否声明为 final
等等。
类访问和属性修饰符:
比如说:我们定义了一个 Employee
类
public class Employee {
...
}
通过javap -v Employee
指令来看一下类的访问标志:flags: ACC_PUBLIC, ACC_SUPER
⑤ 当前类索引、父类索引、接口索引集合
u2 this_class; //当前类
u2 super_class; //父类
u2 interfaces_count; //接口
u2 interfaces[interfaces_count]; //一个类可以实现多个接口Copy to
-
类索引用于确定这个类的全限定名
-
父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了
java.lang.Object
之外,所有的 java 类都有父类,因此除了java.lang.Object
外,所有 Java 类的父类索引都不为 0。 -
接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按
implents
(如果这个类本身是接口的话则是extends
) 后的接口顺序从左到右排列在接口索引集合中。
⑥ 字段表集合
u2 fields_count;//Class 文件的字段的个数
field_info fields[fields_count];//一个类会可以有个字段Copy to clipboardErrorCopied
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
🔸 field info(字段表) 的结构:
- access_flags: 字段的作用域(
public
,private
,protected
修饰符),是实例变量还是类变量(static
修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。 - name_index: 对常量池的引用,表示的字段的名称;
- descriptor_index: 对常量池的引用,表示字段和方法的描述符;
- attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
- attributes[attributes_count]: 存放具体属性具体内容。
上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。
🔸 字段的 access_flags
的取值:
⑦ 方法表集合
u2 methods_count; // Class 文件的方法的数量
method_info methods[methods_count]; // 一个类可以有个多个方法
methods_count
表示方法的数量,而 method_info
表示的方法表。
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
method_info (方法表) 结构:
方法表的 access_flag
取值:
🚨 注意:因为 volatile
修饰符和 transient
修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了 synchronized
、native
、abstract
等关键字修饰方法,所以也就多了这些关键字对应的标志。
⑧ 属性表集合
u2 attributes_count; //此类的属性表中的属性数
attribute_info attributes[attributes_count]; //属性表集合
在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
类加载机制
类的生命周期
一个类的完整生命周期包括以下 7 个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading):GC 将无用对象从内存中卸载
类加载过程
如果 JVM 想要执行 .class 文件,需要将其装进一个 类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进 JVM 里面来:
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
① 加载
类加载过程的第一步,主要完成下面3件事情:
-
通过全类名获取定义此类的二进制字节流
-
将字节流所代表的静态存储结构转换为方法区的运行时数据结构
-
在内存中生成一个代表该类的 Class 对象, 作为方法区这些数据的访问入口
其中二进制字节流可以从以下方式中获取:
- 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
- 从网络中获取,最典型的应用是 Applet。
- 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
- 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。
② 验证
确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
③ 准备
类变量是被 static
修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
public static int value = 123;
如果类变量是常量 final
,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value
被初始化为 123 而不是 0。
public static final int value = 123;
④ 解析
将常量池的符号引用替换为直接引用
💡 符号引用就是一组符号来描述目标,可以是任何字面量。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。
⑤ 初始化
初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>()
方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
<clinit>()
是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,🚨 静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
由于父类的 <clinit>()
方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。例如以下代码:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 2
}
接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()
方法。但接口与类不同的是,执行接口的 <clinit>()
方法不需要先执行父接口的 <clinit>()
方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>()
方法。
虚拟机会保证一个类的<clinit>()
方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的<clinit>()
方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>()
方法完毕。如果在一个类的<clinit>()
方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。
类初始化时机
① 主动引用
虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随之发生):
- 遇到
new
、getstatic
、putstatic
、invokestatic
这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:- 使用
new
关键字实例化对象的时候; - 读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;
- 调用一个类的静态方法的时候。
- 使用
- 使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。 - 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main()
方法的那个类),虚拟机会先初始化这个主类; - 当使用 JDK 1.7 的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
,REF_putStatic
,REF_invokeStatic
的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;
② 被动引用
以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:
-
通过子类引用父类的静态字段,不会导致子类初始化。
System.out.println(SubClass.value); // value 字段在 SuperClass 中定义
-
通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。
SuperClass[] sca = new SuperClass[10];
-
常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
System.out.println(ConstClass.HELLOWORLD);
类加载器
两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。
这里的相等,包括类的 Class 对象的 equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果为 true
,也包括使用 instanceof
关键字做对象所属关系判定结果为 true
。
类加载器分类
- 启动类加载器(Bootstrap ClassLoader):使用 C++ 实现,是虚拟机自身的一部分;加载
<JAVA_HOME>/lib
中的jar包(rt.jar
) - **扩展类加载器(Extension ClassLoader)**这个类加载器是由
ExtClassLoader(sun.misc.Launcher$ExtClassLoader)
实现的。加载<JAVA_HOME>/lib/ext
或者被java.ext.dir
系统变量所指定路径中的所有类库到内存中,开发者可以直接使用扩展类加载器。 - **应用程序类加载器(Application ClassLoader)这个类加载器是由
AppClassLoader(sun.misc.Launcher$AppClassLoader)
实现的。由于这个类加载器是ClassLoader
中的getSystemClassLoader()
方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)**上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型(Parents Delegation Model)
应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。
该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系来实现,而不是继承关系。
① 工作过程
一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。
② 好处
使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。
例如 java.lang.Object
存放在 rt.jar
中,如果编写另外一个 java.lang.Object
并放到 ClassPath 中,程序可以编译通过。由于双亲委派模型的存在,所以在rt.jar
中的 Object
比在 ClassPath 中的 Object
优先级更高,这是因为 rt.jar
中的 Object
使用的是启动类加载器,而 ClassPath 中的 Object
使用的是应用程序类加载器。rt.jar
中的 Object
优先级更高,那么程序中所有的 Object
都是这个 Object
。
③ 实现
以下是抽象类java.lang.ClassLoader
的代码片段,其中的loadClass()
方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException
,此时尝试自己去加载。
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}
④ 自定义类加载器
除了 BootstrapClassLoader
其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader
。🚩 如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader
。
以下代码中的 FileSystemClassLoader
是自定义类加载器,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class
文件),然后读取该文件内容,最后通过 defineClass()
方法来把这些字节代码转换成 java.lang.Class
类的实例。
java.lang.ClassLoader
的 loadClass()
实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写 findClass()
方法。
// 自定义类加载器
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
// 通过 `defineClass()` 方法来把这些字节代码转换成 `java.lang.Class` 类的实例
return defineClass(name, classData, 0, classData.length);
}
}
// 根据类的全名在文件系统上查找类的字节代码文件(.class 文件)
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
动态代理中的字节码生成框架
由于JVM 是通过 .class
字节码文件(也就是二进制信息)加载类的,如果我们在运行期遵循 Java 编译系统组织 .class
字节码文件的格式和结构,生成相应的二进制数据,然后再把这个二进制数据加载转换成对应的类。这样,我们不就完成了在运行时动态的创建一个类。这个思想其实也就是动态代理的思想。
在运行时期按照 JVM 规范对 .class
字节码文件的组织规则,生成对应的二进制数据。当前有很多开源框架可以完成这个功能,如
- ASM
- CGLIB
- Javassist
- …
需要注意的是,CGLIB 是基于 ASM 的。 这里简单对比一下 ASM 和 Javassist:
- Javassist 源代码级 API 比 ASM 中实际的字节码操作更容易使用
- Javassist 在复杂的字节码级操作上提供了更高级别的抽象层。Javassist 源代码级 API 只需要很少的字节码知识,甚至不需要任何实际字节码知识,因此实现起来更容易、更快。
- Javassist 使用反射机制,这使得它比 ASM 慢。
总的来说 ASM 比 Javassist 快得多,并且提供了更好的性能,但是 Javassist 相对来说更容易使用,两者各有千秋。
以 Javassist 为例,我们来看看这些框架在运行时生成 .class
字节码文件的强大能力。
正常来说,我们创建一个类的代码是这样的:
package com.samples;
public class Programmer {
public void code(){
System.out.println("I'm a Programmer,Just Coding.....");
}
}
下面通过 Javassist 创建和上面一模一样的 Programmer
类的字节码:
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.CtNewMethod;
public class MyGenerator {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
// 创建 Programmer 类
CtClass cc= pool.makeClass("com.samples.Programmer");
// 定义方法
CtMethod method = CtNewMethod.make("public void code(){}", cc);
// 插入方法代码
method.insertBefore("System.out.println(\"I'm a Programmer,Just Coding.....\");");
cc.addMethod(method);
// 保存生成的字节码
cc.writeFile("d://temp");
}
}
通过反编译工具打开 Programmer.class
可以看到以下代码:
HotSpot 虚拟机对象
对象的创建
① 类加载检查:
虚拟机遇到一条 new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
② 分配内存:
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配并发问题:
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
③ 初始化零值:
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
④ 设置对象头:
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
⑤ 执行 init
方法:
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new
指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据 和 对齐填充。
-
Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
-
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
Java 程序通过栈上的 reference
数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有 使用句柄 和 直接指针两种:
-
句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,
reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息 -
直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而
reference
中存储的直接就是对象的地址。
这两种对象访问方式各有优势:
- 句柄访问的最大好处是
reference
中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference
本身不需要修改。 - 直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
为零。所以一般来说,执行 new
指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据 和 对齐填充。
-
Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
-
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
Java 程序通过栈上的 reference
数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有 使用句柄 和 直接指针两种:
-
句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,
reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息[外链图片转存中…(img-4EMwhLcu-1642659692329)]
-
直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而
reference
中存储的直接就是对象的地址。[外链图片转存中…(img-kg2vK59J-1642659692330)]
这两种对象访问方式各有优势:
- 句柄访问的最大好处是
reference
中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference
本身不需要修改。 - 直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。