一、概述

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
8be5ec17683b0da3ea5831dc4e14412.png

二、JVM的内存划分

JVM执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

jdk 1.8之前

图片.png

jdk 1.8之后

图片.png
线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

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

三、JVM各区解读

3.1、程序计数器

(Program Counter Register)程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 程序计数器是一块 “线程私有” 的内存,每条线程都有一个独立的程序计数器,能够将切换后的线程恢复到正确的执行位置。
  2. 如果线程正在执行一个 Java 方法,这个计数器记录的正在执行的虚拟机字节码指令的地址,如果执行的是 Native 方法,这个计数器值为空。

    Native 方法: 简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。 “A native method is a Java method whose implementation is provided by non-java code.”

3.2、Java虚拟机栈

(Java Virtual Machine Stacks)
1、Java虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame),栈帧中存储着局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,会对应一个栈帧在虚拟机栈中入栈到出栈的过程。与程序计数器一样,Java虚拟机栈也是线程私有的。
a.局部变量表主要存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置),returnAddress类型(指向了一条字节码指令的地址)。

b. 操作栈帧,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是 用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进 行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

c.指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时的常量池

d.方法返回地址,当一个方法执行完毕后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法的返回地址。
由于每个线程执行正在执行的方法可能不同,因此每个线程都有一个Java栈,互不干扰。
2、Java虚拟机规范中对这个区域规定了两种异常状况:

StackOverflowError(栈溢出)

若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 异常。

  1. package com.lxh.memory;
  2. public class StackOverFlowErrorTest {
  3. private int count = 0;
  4. // 方法自己调用自己,在虚拟机栈中建立一个又一个栈帧,直到到达栈深度
  5. public void testStack() {
  6. count++;
  7. testStack();
  8. }
  9. public void test() {
  10. try {
  11. testStack();
  12. } catch (Throwable e) {
  13. System.out.println(e);
  14. System.out.println("stack height:" + count);
  15. }
  16. }
  17. public static void main(String[] args) {
  18. new StackOverFlowErrorTest().test();
  19. }
  20. }

运行结果:
image.png

OutOfMemoryError(内存溢出)

当可动态扩展的虚拟机栈在扩展时无法申请到足够的内存,就会抛出该异常。 Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
(这行代码不要轻易尝试,容易内存飚满 o(╥﹏╥)o)
注意,特别提示一下,如果要尝试运行上面这段代码,记得要先保存当前的工作。由于在Windows平台的虚拟机,Java的线程是映射到操作系统的内核线程上的,因此上述代码执行时有较大的风险,可能会导致操作系统假死。

  1. package com.lxh.memory;
  2. public class OutOfMemoryTest {
  3. private void dontStop() {
  4. while (true) {
  5. }
  6. }
  7. //不断创建线程
  8. public void stackLeakByThread() {
  9. try {
  10. while (true) {
  11. Thread thread = new Thread(new Runnable() {
  12. @Override
  13. public void run() {
  14. dontStop();
  15. }
  16. });
  17. thread.start();
  18. }
  19. } catch (Throwable e) {
  20. System.out.println(e);
  21. System.out.println("stack height:" + count);
  22. }
  23. }
  24. public static void main(String[] args) throws Throwable {
  25. OutOfMemoryTest oom = new OutOfMemoryTest();
  26. oom.stackLeakByThread();
  27. }
  28. }

运行结果:
image.pngimage.png
1. JVM - 图71. JVM - 图8
Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
3、对于虚拟机栈内存形象理解
可以把虚拟机栈内存看做一个弹夹上述testStack()方法相当于子弹,每执行一次该方法就相当于往弹夹里压入一颗子弹(栈帧),而栈深度就相当于弹夹的容量,即栈帧的数量,当方法执行过多时,弹夹容量达到限制没法再压入子弹,就会出现StackOverflowError,若 Java 虚拟机栈的内存大小允许动态扩展,就相当于把原来的弹夹换成了扩容弹夹,可以再压入子弹,当扩容弹夹再次压满时就会出现OutOfMemoryError

扩展:那么方法/函数如何调用?
Java 栈可以类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回方式都会导致栈帧被弹出。

3.3、Java本地方法栈

(Native Method Stack)
本地方法栈(Native Method Stack)与Java虚拟机栈作用很相似,它们的区别在于虚拟机栈为虚拟机执行Java方法(即字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

在虚拟机规范中对本地方法栈中使用的语言、方式和数据结构并无强制规定,因此具体的虚拟机可实现它。甚至有的虚拟机(Sun HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一。与虚拟机一样,本地方法栈会抛出StackOverflowError和OutOfMemoryError异常。

3.4、堆(Heap)

对于大多数应用而言,Java堆(Heap)是Java虚拟机所管理的内存中最大的一块,它被所有线程共享的,在虚拟机启动时创建。此内存区域唯一的目的是存放对象实例,几乎所有的对象实例都在这里分配内存,且每次分配的空间是不定长的。在Heap 中分配一定的内存来保存对象实例,实际上只是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在Stack中),在Heap 中分配一定的内存保存对象实例和对象的序列化比较类似。对象实例在Heap 中分配好以后,需要在Stack中保存一个4字节的Heap 内存地址,用来定位该对象实例在Heap 中的位置,便于找到该对象实例。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

1. JVM - 图9
JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
1. JVM - 图10
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

🐛 修正(参见:issue552 :“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。 动态年龄计算的代码如下

  1. uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
  2. //survivor_capacity是survivor空间的大小
  3. size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
  4. size_t total = 0;
  5. uint age = 1;
  6. while (age < table_size) {
  7. total += sizes[age];//sizes数组是每个年龄段对象大小
  8. if (total > desired_survivor_size) break;
  9. age++;
  10. }
  11. uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
  12. ...
  13. }

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见:Default Java 8 max heap size)
  3. ……

Java堆是垃圾收集器管理的主要区域,因此也被称为 “GC堆(Garbage Collected Heap)” 。从内存回收的角度看内存空间可如下划分:

1. JVM - 图11
a.新生代(Young):新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低。在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。如果把新生代再分的细致一点,新生代又可细分为Eden空间、From Survivor空间、To Survivor空间,默认比例为8:1:1。
b.老年代(Tenured/Old):在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。
c.永久代(Perm):永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

其中新生代和老年代组成了Java堆的全部内存区域,而永久代不属于堆空间,其中新生代和老年代组成了Java堆的全部内存区域,而永久代不属于堆空间,它在JDK 1.8以前被Sun HotSpot虚拟机用作方法区的实现.

堆内存溢出演示:

  1. package com.lxh.memory;
  2. import java.util.ArrayList;
  3. import java.util.List;
  4. public class OutOfMemory {
  5. public static void main(String[] args){
  6. List list=new ArrayList();
  7. for(;;){
  8. int[] tmp=new int[1000000];
  9. list.add(tmp);
  10. }
  11. }
  12. }

运行结果:
image.png

3.5、方法区、元空间

3.5.1 方法区(Method Area)

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。Object Class Data(类定义数据)是存储在方法区的,此外,常量静态变量JIT编译后的代码也存储在方法区。正因为方法区所存储的数据与堆有一种类比关系,所以它还被称为 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。

3.5.2 方法区和永久代的关系

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

3.5.3 常用参数

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

-XX:PermSize=N //方法区 (永久代) 初始大小 -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
下面是一些常用参数:

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小 与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

3.5.4 JDK 1.8以前的永久代(PermGen)

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集,也就是说,Java虚拟机规范只是规定了方法区的概念和它的作用,并没有规定如何去实现它。对于JDK 1.8之前的版本,HotSpot虚拟机设计团队选择把GC分代收集扩展至方法区,即用永久代来实现方法区,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他的虚拟机(如Oracle JRockit、IBM J9等)来说是不存在永久代的概念的。

如果运行时有大量的类产生,可能会导致方法区被填满,直至溢出。常见的应用场景如:
1.Spring和ORM框架使用CGLib操纵字节码对类进行增强,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。
2.大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)。
3.基于OSGi的应用(即使是同一个类文件,被不同的类加载器加载也会视为不同的类)。
这些都会导致方法区溢出,报出java.lang.OutOfMemoryError: PermGen space。

3.5.5 JDK 1.8的元空间(Metaspace)

整个永久代有一个 JVM 本身设置固定大小上线,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到 java.lang.OutOfMemoryError。你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

3.5.6 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

下图来自《深入理解 Java 虚拟机》第 3 版 2.2.5
1. JVM - 图13

  1. 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

    当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

  1. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

3.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

🐛 修正(参见:issue747reference

  1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
  2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代
  3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

相关问题:JVM 常量池中存储的是对象还是引用呢?: https://www.zhihu.com/question/57109429/answer/151717241 by RednaxelaFX

众所周知,JDK1.8版本中,String常量池已经从方法区中的运行时常量池分离到堆中了,那么在堆中的String常量池里存的是String对象还是引用呢?直接查看API:
image.png
翻译:String类的intern()方法:一个初始为空的字符串池,它由类String独自维护。当调用 intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串。否则,将此String对象添加到池中,并返回此String对象(注意是常量池中的对象,不是堆中的对象)的引用。 对于任意两个字符串s和t,当且仅当s.equals(t)为true时,s.intern() == t.intern()才为true。所有字面值字符串和字符串赋值常量表达式都使用 intern方法进行操作。

总结

JDK1.8版本的字符串常量池中存的是字符串对象,以及字符串常量值。

附上常考面试题:

  1. 输出结果?创建了几个对象?

    1. String s1 = "abc";
    2. String s2 = "abc";
    3. System.out.println(s1==s2);

    结果输出:

    1. true

    创建了1个对象。采用字面值的方式创建一个字符串时,JVM首先会去字符串池中查找是否存在”abc”这个对象,如果不存在,则在字符串池中创建”abc”这个对象,然后将池中”abc”这个对象的引用地址返回给”abc”对象的引用s1,这样s1会指向池中”abc”这个字符串对象;如果存在,则不创建任何对象,直接将池中”abc”这个对象的地址返回,赋给引用s2。因为s1、s2都是指向同一个字符串池中的”abc”对象,所以结果为true。

  2. 输出结果?创建了几个对象?

    1. String s3 = new String("xyz");
    2. String s4 = new String("xyz");
    3. System.out.println(s3==s4);

    结果输出:

    1. false

    创建了3个对象。采用new关键字新建一个字符串对象时,JVM首先在字符串池中查找有没有”xyz”这个字符串对象,如果有,则不在池中再去创建”xyz”这个对象了,直接在堆中创建一个”xyz”字符串对象,然后将堆中的这个”xyz”对象的地址返回赋给引用s3,这样,s3就指向了堆中创建的这个”xyz”字符串对象;如果没有,则首先在字符串池中创建一个”xyz”字符串对象,然后再在堆中创建一个”xyz”字符串对象,然后将堆中这个”xyz”字符串对象的地址返回赋给s3引用,这样,s3指向了堆中创建的这个”xyz”字符串对象。s4则指向了堆中创建的另一个”xyz”字符串对象。s3 、s4是两个指向不同对象的引用,结果当然是false。

  3. 代码详解

    1. String s1 = new StringBuilder().append("ja").append("va1").toString();
    2. System.out.println(s1.intern() == s1);

    输出结果:

    1. true

    详解:StringBuilder().toString()这个方法虽然是new了一个String对象,但其实和”java1”是一样的,大家可以看下源码,这里的s1.intern()返回的是常量池中字符串的引用,所以s1.intern() == s1。

    链接:JDK1.8字符串常量池里存的是String对象还是引用? - 程序员曾奈斯 - 博客园
    作者:曾奈斯
    出处:博客园

3.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

四、HotSpot 虚拟机对象探秘

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

4.1 对象的创建

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。
图片.png

Step1:类加载检查

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

Step2:分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的
图片.png
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

    Step3:初始化零值

    内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

    Step4:设置对象头

    初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

    Step5:执行 init 方法

    在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

4.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充
Hotspot 虚拟机的对象头包括两部分信息第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

4.3 对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有① 使用句柄② 直接指针两种:

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

图片.png

  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

图片.png
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

五、重点补充内容

5.1 字符串常量池常见问题

我们先来看一个非常常见的面试题:String 类型的变量和常量做“+”运算时发生了什么?
先来看字符串不加 final 关键字拼接的情况(JDK1.8):

  1. String str1 = "str";
  2. String str2 = "ing";
  3. String str3 = "str" + "ing";//常量池中的对象
  4. String str4 = str1 + str2; //在堆上创建的新的对象
  5. String str5 = "string";//常量池中的对象
  6. System.out.println(str3 == str4);//false
  7. System.out.println(str3 == str5);//true
  8. System.out.println(str4 == str5);//false

注意 :比较 String 字符串的值是否相等,可以使用 equals() 方法。 String 中的 equals 方法是被重写过的。 Object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是字符串的值是否相等。如果你使用 == 比较两个字符串是否相等的话,IDEA 还是提示你使用 equals() 方法替换。

图片.png

对于基本数据类型来说,== 比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。

对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。

字符串常量池 是 JVM 为了提升性能和减少内存消耗针为字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。 String aa = “ab”; // 放在常量池中 String bb = “ab”; // 从常量池中查找 System.out.println(“aa==bb”);// true JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区。JDK1.7 的时候,字符串常量池被从方法区拿到了堆中。

并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到: 1. JVM - 图20 常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。 对于 String str3 = “str” + “ing”; 编译器会给你优化成 String str3 = “string”; 。 并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  1. 基本数据类型(byte、boolean、short、char、int、float、long、double)以及字符串常量
  2. final 修饰的基本数据类型和字符串变量
  3. 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

因此,str1 、 str2 、 str3 都属于字符串常量池中的对象。
引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

  1. String str4 = new StringBuilder().append(str1).append(str2).toString();

因此,str4 并不是字符串常量池中存在的对象,属于堆上的新对象。
我画了一个图帮助理解:
1. JVM - 图21
我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。
不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。

  1. final String str1 = "str";
  2. final String str2 = "ing";
  3. // 下面两个表达式其实是等价的
  4. String c = "str" + "str2";// 常量池中的对象
  5. String d = str1 + str2; // 常量池中的对象
  6. System.out.println(c == d);// true

被 final 关键字修改之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就想到于访问常量。
如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。
示例代码如下(str2 在运行时才能确定其值):

  1. final String str1 = "str";
  2. final String str2 = getStr();
  3. String c = "str" + "str2";// 常量池中的对象
  4. String d = str1 + str2; // 常量池中的对象
  5. System.out.println(c == d);// false
  6. public static String getStr() {
  7. return "ing";
  8. }

我们再来看一个类似的问题!

  1. String str1 = "abcd";
  2. String str2 = new String("abcd");
  3. String str3 = new String("abcd");
  4. System.out.println(str1==str2);
  5. System.out.println(str2==str3);

上面的代码运行之后会输出什么呢?
答案是:

  1. false
  2. false

这是为什么呢?
我们先来看下面这种创建字符串对象的方式:

  1. // 从字符串常量池中拿对象
  2. String str1 = "abcd";

这种情况下,jvm 会先检查字符串常量池中有没有”abcd”,如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向”abcd””;
因此,str1 指向的是字符串常量池的对象。
我们再来看下面这种创建字符串对象的方式:

  1. // 直接在堆内存空间创建一个新的对象。
  2. String str2 = new String("abcd");
  3. String str3 = new String("abcd");

只要使用 new 的方式创建对象,便需要创建新的对象
使用 new 的方式创建对象的方式如下,可以简单概括为 3 步:

  1. 在堆中创建一个字符串对象
  2. 检查字符串常量池中是否有和 new 的字符串值相等的字符串常量
  3. 如果没有的话需要在字符串常量池中也创建一个值相等的字符串常量,如果有的话,就直接返回堆中的字符串实例对象地址。

因此,str2 和 str3 都是在堆中新创建的对象。
字符串常量池比较特殊,它的主要使用方法有两种:

  1. 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  2. 如果不是用双引号声明的 String 对象,使用 String 提供的 intern() 方法也有同样的效果。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7 之前(不包含 1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7 以及之后,字符串常量池被从方法区拿到了堆中,jvm 不会在常量池中创建该对象,而是将堆中这个对象的引用直接放到常量池中,减少不必要的内存开销。

示例代码如下(JDK 1.8) :

  1. String s1 = "Javatpoint";
  2. String s2 = s1.intern();
  3. String s3 = new String("Javatpoint");
  4. String s4 = s3.intern();
  5. System.out.println(s1==s2); // True
  6. System.out.println(s1==s3); // False
  7. System.out.println(s1==s4); // True
  8. System.out.println(s2==s3); // False
  9. System.out.println(s2==s4); // True
  10. System.out.println(s3==s4); // False

总结

  1. 对于基本数据类型来说,==比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。
  2. 在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
  3. 一般来说,我们要尽量避免通过 new 的方式创建字符串。使用双引号声明的 String 对象( String s1 = “java” )更利于让编译器有机会优化我们的代码,同时也更易于阅读。
  4. 被 final 关键字修改之后的 String 会被编译器当做常量来处理,编译器程序编译期就可以确定它的值,其效果就想到于访问常量。

    5.2 String s1 = new String(“abc”);这句话创建了几个字符串对象?

    会创建 1 或 2 个字符串:
  • 如果字符串常量池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。
  • 如果字符串常量池中没有字符串常量“abc”,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

验证:

  1. String s1 = new String("abc");// 堆内存的地址值
  2. String s2 = "abc";
  3. System.out.println(s1 == s2);// 输出 false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
  4. System.out.println(s1.equals(s2));// 输出 true

结果:

  1. false
  2. true

5.3 8 种基本类型的包装类和常量池

Java 基本类型的包装类的大部分都实现了常量池技术。
Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True Or False。
两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。

  1. Integer i1 = 33;
  2. Integer i2 = 33;
  3. System.out.println(i1 == i2);// 输出 true
  4. Integer i11 = 333;
  5. Integer i22 = 333;
  6. System.out.println(i11 == i22);// 输出 false
  7. Double i3 = 1.2;
  8. Double i4 = 1.2;
  9. System.out.println(i3 == i4);// 输出 false

Integer 缓存源代码

  1. /**
  2. *此方法将始终缓存-128 到 127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
  3. */
  4. public static Integer valueOf(int i) {
  5. if (i >= IntegerCache.low && i <= IntegerCache.high)
  6. return IntegerCache.cache[i + (-IntegerCache.low)];
  7. return new Integer(i);
  8. }
  9. private static class IntegerCache {
  10. static final int low = -128;
  11. static final int high;
  12. static final Integer cache[];
  13. }

Character 缓存源码

  1. public static Character valueOf(char c) {
  2. if (c <= 127) { // must cache
  3. return CharacterCache.cache[(int)c];
  4. }
  5. return new Character(c);
  6. }
  7. private static class CharacterCache {
  8. private CharacterCache(){}
  9. static final Character cache[] = new Character[127 + 1];
  10. static {
  11. for (int i = 0; i < cache.length; i++)
  12. cache[i] = new Character((char)i);
  13. }
  14. }

Boolean 缓存源码

  1. public static Boolean valueOf(boolean b) {
  2. return (b ? TRUE : FALSE);
  3. }

如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。
下面我们来看一下问题。下面的代码的输出结果是 true 还是 flase 呢?

  1. Integer i1 = 40;
  2. Integer i2 = new Integer(40);
  3. System.out.println(i1==i2);

Integer i1=40 这一行代码会发生拆箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是常量池中的对象。而Integer i1 = new Integer(40) 会直接创建新的对象。
因此,答案是 false 。你答对了吗?
记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较
1. JVM - 图22

Integer 比较更丰富的一个例子

  1. Integer i1 = 40;
  2. Integer i2 = 40;
  3. Integer i3 = 0;
  4. Integer i4 = new Integer(40);
  5. Integer i5 = new Integer(40);
  6. Integer i6 = new Integer(0);
  7. System.out.println(i1 == i2);// true
  8. System.out.println(i1 == i2 + i3);//true
  9. System.out.println(i1 == i4);// false
  10. System.out.println(i4 == i5);// false
  11. System.out.println(i4 == i5 + i6);// true
  12. System.out.println(40 == i5 + i6);// true

i1 , i2 , i3 都是常量池中的对象,i4 , i5 , i6 是堆中的对象。
i4 == i5 + i6 为什么是 true 呢?因为, i5 和 i6 会进行自动拆箱操作,进行数值相加,即 i4 == 40 。 Integer 对象无法与数值进行直接比较,所以 i4 自动拆箱转为 int 值 40,最终这条语句转为 40 == 40 进行数值比较。

参考