分类 标签 存档 社区 博客 友链 GitHub 订阅 搜索

Java - JVM

443 浏览

ZERO

    持续更新 请关注:https://zorkelvll.cn/blogs/zorkelvll/articles/2018/12/22/1545481280495

背景

     本文主要是记录在学习 Java - JVM 过程中的一些知识点备忘!

知识点规整:

一、

  • 介绍下 Java 内存区域(运行时数据区):程序计数器、虚拟机栈、本地方法栈、堆、方法区、运行时常量池、直接内存

  • Java 对象的创建过程:五步,需要能够默认出来并且知道每一步虚拟机都做了些什么

  • 对象的访问定位的两种方式:句柄、直接指针

  • String 类和常量池

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

二、

  • 如何判断对象是否死亡?:两种方法,引用计数法和可达性分析法
  • 强引用、软引用、弱引用、虚引用?:虚引用与软引用和弱引用的区别、使用软引用能带来的好处
  • 如何判断一个常量是废弃常量?
  • 如何判断一个类是无用的类?
  • 垃圾收集有哪些算法,各自的特点?:标记 - 清除算法、复制算法、标记 - 整理算法、分代收集算法
  • HotSpot 为什么要分为新生代和老年代?:为了提高垃圾收集的效率(分代收集算法)
  • 常见的垃圾回收器有哪些?:Serial 收集器、ParNew 收集器、Parallel Scavenge 收集器、CMS 收集器、G1 收集器
  • 介绍 CMS,G1 收集器?
  • Minor GC 和 Full GC 有什么不同?

20181229 1、OutOfMemoryError VS StackOverflowError

OutOfMemoryError:线程太多,内存不够新建线程;堆溢出,“内存泄漏”和 “内存溢出” 这两种情况会导致该错误

StackOverflowError:方法调用层次太深,内存不够新建栈帧;栈溢出,即在方法运行的时候,请求新建栈帧时,栈所剩空间小于栈帧所需空间;例如,递归调用方法(一个方法调用自己这个方法就好了),不停地产生栈帧,一直把栈空间堆满,直到抛出异常

20181224

在 JVM 中,” 内存是如何分配和回收的?”=>“哪些垃圾需要回收?”=>“什么时候回收?”=>“如何回收?”

当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些 “自动化” 的技术实施必要的监控和调节。

1、JVM 内存分配与回收

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,java 自动内存管理最核心的功能是对内存中的对象的分配与回收。

JDK1.8 之前的堆内存分为新生代(Eden 区、Survivor1 区、Survivor2 区)、老年代、永久代;在 JDK1.8 中移除了整个永久代,取而代之的是一个叫做元空间(Metaspace)的区域(永久代使用的是 JVM 的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)

堆内存常见分配策略:对象优先在 Eden 区分配;大对象直接进入老年代;长期存活的对象将进入老年代

1.1、对象优先在 eden 区分配

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法!

大多数情况下,对象在新生代中 eden 区分配;当 eden 区没有足够空间进行分配时,虚拟机将发起一起 Minor GC;

MinorGC 和 Full GC 有什么不同:新生代 GC(Minor GC),指发生在新生代的垃圾收集动作,MinorGC 非常频繁,回收速度一般也比较快;老年代 GC(Major GC/Full GC),指发生在老年代的 GC,出现了 MajorGC 经常会伴随着至少一次的 Minor GC(并非绝对),MajorGC 的速度一般会比 MinorGC 慢 10 倍以上

测试:

public class GCTest {
  public static void main(String[] args) {
    byte[] allocation1, allocation2;
    allocation1 = new byte[30900*1024];
    //allocation2 = new byte[900*1024];
  }
}

在 Intellij IDEA 中通过 “Run As -> Run Configurations…” 方式运行,且添加的 VM arguments 参数为“-XX:+PrintGCDetails”,可以查看到运行结果中的“PSYoungGen 新生代(eden space 100% used 表示使用完全);ParOldGen 老年代;MetaSpace 元空间,对应于 JDK1.8 之前的永久代”

=> 可以发现,eden 区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用 2000 多 K 内存)!如果再为 allocation2 分配内存,即也取消上面的注释,也再为 allocation2 分配内存!可以看到运行结果中的 “PSYoungGen 新生代(eden space 3% used;from space 15%;to space 0%;);ParOldGen 老年代 (object space 35%)”!! 这是由于:

在给 allocation2 分配内存的时候,eden 区内存几乎已经被分配完了;而当 eden 区没有足够空间进行分配时,虚拟机将发起一次 MinorGC;在 MinorGC 期间又发现 allocation1 无法存入 Survivor 空间,所以只能通过分配担保机制把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 llocation1,所以不会出现 FullGC; 执行 MinorGC 后,后面分配的队形如果能够存在 eden 区的话,还是会在 eden 区分配内存,可以通过如下测试代码进行验证:

public class GCTest {
  public static void main(String[] args) {
    byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
    allocation1 = new byte[32000*1024];
    allocation2 = new byte[1000*1024];
    allocation3 = new byte[1000*1024];
    allocation4 = new byte[1000*1024];
    allocation5 = new byte[1000*1024];
  }
}

1.2、大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组);为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率,因此将大对象直接放入老年代

1.3、长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须识别哪些对象应放在新生代,哪些队形应放在老年代;为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 MinorGC 后仍然能够存活,并且能够被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1;

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中;对象晋升到老年代的年龄阈值,可以通过参数 - XX:MaxTenuringThreshold 来设置

1.4、动态对象年龄判定

为了更好地适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄

2、如何判断对象是否已经死亡?

堆中几乎放着所有的对象实例,对垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)

对象已经死亡?

  • 如何判断一个对象已经无效?:引用计数法、可达性分析算法
  • 再谈引用:强引用、软引用、弱引用、虚引用
  • 不可达的对象并非 “非死不可”
  • 如何判断一个常量是废弃常量?
  • 如何判断一个类是无用的类?

2.1、引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。

该方法实现简单、效率高;但是目前主流的虚拟机中并没有选择这个算法来管理内存,主要是因为它很难解决对象之间相互循环引用的问题!所谓对象之间的相互引用问题,比如有两个对象 objA 和 objB,这两个对象互相引用着对象,除此之外再无任何引用,此时由于它们互相引用导致它们的引用计数器都不是 0,于是引用计数算法无法通知 GC 回收它们。

2.2、可达性分析算法

该算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链的话,则证明此对象是不可用的

2.3、再谈引用???

3、垃圾收集算法

垃圾收集算法共分为 “标记 - 清除算法、复制算法、标记 - 整理算法、分代收集算法” 四种

3.1、标记 - 清除算法

该算法分为 “标记” 和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象;该算法是最基础的收集算法,效率也很高,但是会带来两个明显的问题 “效率问题” 和“空间问题(标记清除后会产生大量不连续的碎片)”

3.2、复制算法

为了解决效率问题,“复制” 收集算法出现了;该算法是将内存分为大小相同的两块,每次使用其中的一块;当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉;这样就使得每次的内存回收都是对内存区间的一半进行回收

3.3、标记 - 整理算法

根据老年代的特点而给出的一种标记算法,标记过程仍然与标记 - 清除算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存

3.4、分代收集算法

当前虚拟机的垃圾收集均采用分代收集算法,该算法只是根据对象存活周期的不同将内存分为几块:一般将 java 堆分为新生代和老年代,这样就可以根据各个年代的特点去选择合适的算法!

比如:

  • 新生代:在新生代中,每次收集都会大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集
  • 老年代:在老年代中,对象存活几率是比较高的,而且也没有额外的空间对它进行分配担保,所以选择标记 - 清除或者标记 - 整理算法进行垃圾收集

=>?HotSpot 为什么要分为新生代和老年代?:这是为了提高垃圾收集的效率,具体见 3.4 分代收集算法

4、垃圾收集器

垃圾收集算法是内存回收的方法论,而垃圾收集器则是内存回收的具体实现!到目前为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,只能根据具体应用场景去选择适合自己的垃圾收集器!

4.1、Serial 收集器 - Stop The World

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了,这是一个单线程收集器!它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,最重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(“Stop The World”),直到它收集结束

新生代采用复制算法,老年代采用标记 - 整理算法

尽管 Stop The World 带来了不良的用户体验(在后续的垃圾收集器设计中停顿时间在不断地缩短【仍然还有停顿,寻找最优秀的垃圾收集器的过程还在继续】),但是它简单而高效(由于没有线程交互的开销,自然可以获得很高的单线程收集效率),Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择

4.2、ParNew 收集器

ParNew 收集器,其实就是 Serial 收集器的多线程版本;出了使用多线程进行垃圾收集外,其他行为(控制参数、收集算法、回收策略等等)均和 Serial 收集器完全一样

新生代采用复制算法,老年代采用标记 - 整理算法

该算法是许多运行在 Server 模式下的虚拟机的首要选择,出了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器)配合工作

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

4.3、Parallel Scavenge 收集器

Parallel Scavenge 收集器类似于 ParNew 收集器;-XX:+UseParallelGC 为设置 “使用 Parallel 收集器 + 老年代串行”,-XX:+UseParallelOldGC 为设置 “使用 Parallel 收集器 + 老年代并行”

Parallel Scavenge 收集器收集器关注点是吞吐量(高效率地利用 CPU),而 CMS 等垃圾收集器更多的是关注用户线程的挺短时间(提供用户体验);所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在的话可以选择把内存管理优化交给虚拟机完成也是一个不错的选择

新生代采用复制算法,老年代采用标记 - 整理算法

4.4、Serial Old 收集器

Serial 收集器的老年代版本,同样是一个单线程收集器;主要有两大用途:一是用于在 JDK1.5 及之前的版本中与 Parallel Scavenge 收集器搭配使用,二是作为 CMS 收集器的后备方案

4.5、Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本,使用多线程和标记 - 整理算法,在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器 和 Parallel Old 收集器

4.6、CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合在注重用户体验的应用上使用

CMS 收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集器与用户线程(基本上)同时工作

CMS 收集器是一种标记 - 清除算法,其运作过程相比较前面几种收集器更加复杂,整个过程分为四个步骤:

  • 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快;
  • 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象;但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象,因为用户线程可能会不断地更新引用域,所以 GC 线程无法保证可达性分析的实时性;所以这个算法会跟踪记录这些发生引用更新的地方
  • 重新标记:这个阶段就是为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除:开启用户线程,同时 GC 线程开始对为标记的区域做清扫

主要优点是:并发收集、低停顿;

主要缺点是:对 CPU 资源敏感;无法处理浮动垃圾;使用的回收算法标记 - 清除算法会导致收集结束时有大量空间碎片产生

4.7、G1 收集器

G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征,它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 停顿时间;部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念
  • 空间整合:与 CMS 的 “标记–清理” 算法不同,G1 从整体来看是基于 “标记整理” 算法实现的收集器;从局部上来看是基于 “复制” 算法实现的
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也是其名称 Garbage-First 的由来),这种使用 Region 划分内存空间以及优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高得收集效率(把内存化整为零)

20181223-1

1、运行时数据区域

Java 虚拟机在执行 java 程序的过程中会把它管理的内存划分成若干个不同的数据区域:有些区域是线程私有的(程序计数器、虚拟机栈、本地方法栈),有些区域是线程共享的(堆、方法区、直接内存)

1.1、程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程锁执行的字节码的行号指示器!

字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成!

另外,为了线程切换后能够恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,因此这类内存区域也被称为” 线程私有” 的内存!

程序计数器的作用主要有两个:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程切换回来的时候能够知道该线程上次运行到哪儿了

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

1.2、java 虚拟机栈

与程序计数器一样,java 虚拟机栈也是线程私有的,它的生命周期与线程相同,描述的是 java 方法执行的内存模型

java 内存可以粗略低区分为堆内存(Heap)和栈内存(Stack),其中栈也即现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分(实际上,java 虚拟机栈是由一个个栈帧组成,而每个栈帧都拥有:局部变量表、操作数栈、动态链接、方法出口信息)

局部变量表主要用于存放编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)

java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError

  • StackOverFlowError:若 java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 java 虚拟机栈的最大深度的时候,则抛出该异常
  • OutOfMemoryError:若 java 虚拟机栈的内存大小允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展了,则会抛出该异常

java 虚拟机栈也是线程私有的,每个线程均有各自的 java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡

1.3 本地方法栈

与虚拟机栈所发挥的作用非常相似,区别在于:虚拟机栈为虚拟机执行 java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务!在 HotSpot 虚拟机中,本地方法栈与 Java 虚拟机栈合二为一!

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常

1.4、堆

Java 虚拟机所管理的内存中最大的一块,java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建!此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组均在这里分配内存

Java 堆是垃圾回收器管理的主要区域,因此也被称作 GC 堆!从垃圾回收角度出发,由于现在垃圾收集器基本上都是采用分代垃圾收集算法,因此 java 堆也还可以细分为 “新生代和老年代(以及 jdk1.7 之前还有:永久代)”,再细致一点新生代可以细分 “Eden 空间、From Survivor、To Survivor 空间等”,进一步划分的目的是为了更好地回收内存或者说更快地分配内存

在 JDK1.8 中移除了整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是 JVM 的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)

1.5、方法区

方法区与 java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据!

虽然 java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名 Non-Heap(非堆),目的是为了与 java 堆区分开来

HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价!仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 java 堆一样管理这部分内存了!但这并不是一个好主意,因为这样更容易遇到内存溢出问题

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就 “永久存在” 了

1.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成各种字面量和符合引用)

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常

JDK1.7 + 版本 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆中开辟了一块区域存放运行时常量池

1.7、直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现

JDK1.4 中新加入的 NIO(New Input/Output)类,引入了一种基于通道 Channel 与缓存区 Buffer 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作!!这样能够在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制

2、HotSpot 虚拟机对象探秘

以下详细介绍 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程

2.1、对象的创建(需要做到可以默写出来)

  • 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过、解析和初始化过;如果没有,那必须先执行相应的类加载过程
  • 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

内存分配的两种方式:内存分配方式有 “指针碰撞” 和“空闲列表”两种,选择哪种分配方式由 java 堆内存是否规整决定,而 java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定!(是标记 - 清除,还是标记 - 压缩,另外复制算法内存也是规整的)

内存分配并发问题:虚拟机采用两种方式来保证线程安全,

(a)、CAS + 失败重试:CAS 是乐观锁的一种实现方式,所谓乐观锁就是 “每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止”,虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性

(b)、TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

  • 初始化零值

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

  • 设置对象头

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

  • 执行 init 方法

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

2.2、对象的内存布局

在 HotSpot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充

对象头:包含两部分信息,第一部分是用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),第二部分是类型指针(即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例)

实例数据:是对象真正存储的有效信息,也是程序中所定义的各种类型的字段内容

对齐填充:这一部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用!因为 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍;而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

2.3、对象的访问定位

创建对象就是为了使用对象,java 程序通过栈上的 reference 数据来操作堆上的具体对象;对象的访问方式有虚拟机实现而定,目前主流的访问方式有 “使用句柄” 和“直接指针”两种:

  • 句柄:如果使用句柄的话,那么 java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
  • 直接指针:如果使用直接指针访问,java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址

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

3、String 类和常量池

3.1、String 对象的两种创建方式:

String s1 = "aokay";
String s2 = new String("aokay");
System.out.println(s1 == s2); //false

前者是在常量池中拿对象,后者是直接在堆内存空间中创建一个新的对象(注意:只要是使用 new 方法,便均是需要创建新的对象)

3.2、String 类型的常量池

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中
  • 如果不是使用双引号声明的 String 对象,则可以使用 String 提供的 intern 方法,该方法是一个 Native 方法,其作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中国创建的字符串的引用,如:
String s1 = new String("aokay");//创建了两个对象,先有字符串"aokay"放入常量池,然后 new 了一份字符串"aokay"放入Java堆(字符串常量"aokay"在编译期就已经确定放入常量池,而 Java 堆上的"aokay"是在运行期初始化阶段才确定),然后 Java 栈的 s1 指向Java堆上的"aokay"。
String s2 = s1.intern();
String s3 = "aokay";
System.out.println(s1 == s2);//false,因为s1是堆内存中的String对象,s2是常量池的中的String对象
System.out.println(s3 == s2);//true,因为两个均是常量池中的String对象

3.3、String 字符串拼接

String s1 = "aokay";
Srring s2 = "Tech";

String s3 = "aokay" + "Tech";//常量池中的对象
String s4 = s1 + s2;//在堆上创建的新的对象
String s5 = "aokayTech";//常量池中的对象
System.out.println(s3 == s4);//false
System.out.println(s3 == s5);//true
System.out.println(s4 == s5);//false

=》尽量避免多个字符串拼接,因为这样会重新创建对象;如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer

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

  • java 基本类型的包装类大部分都实现了常量池技术,即 Byte、Short、Integer、Long、Character、Boolean, 这 5 中包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象
  • 两种浮点数类型的包类型 Float、Double 并没有实现常量池技术
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出true
Integer i11 = 333;
Integer i22 = 333;
System.out.println(i11 == i22);// 输出false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出false

Integer 缓存源代码

    /**
    *此方法将始终缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
    */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache\[i + (-IntegerCache.low)\];
        return new Integer(i);
    }
  • Integer i1=40;Java 在编译的时候会直接将代码封装成 Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  • Integer i1 = new Integer(40); 这种情况下会创建新的对象
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);//输出false
  Integer i1 = 40;
  Integer i2 = 40;
  Integer i3 = 0;
  Integer i4 = new Integer(40);
  Integer i5 = new Integer(40);
  Integer i6 = new Integer(0);
  
  System.out.println("i1=i2   " + (i1 == i2)); //true,均是在常量池中的
  System.out.println("i1=i2+i3   " + (i1 == i2 + i3));//true,
  System.out.println("i1=i4   " + (i1 == i4));//false,一个是常量池中的对象,一个是堆中的对象
  System.out.println("i4=i5   " + (i4 == i5));//true,均是堆中的对象
  System.out.println("i4=i5+i6   " + (i4 == i5 + i6));//true,因为+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。   
  System.out.println("40=i5+i6   " + (40 == i5 + i6));//true,原因如上

说明:本 JavaGuide 系列博客为来源于https://github.com/Snailclimb/JavaGuide 等学习网站或项目中的知识点,均为自己手打键盘系列且内容会根据继续学习情况而不断地调整和完善!

评论  
留下你的脚步
推荐阅读