欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

入门 JVM 这一篇就够了

程序员文章站 2024-03-16 11:03:16
...

写在前面:
本来是想简短点的,可是越写越多
这里主要介绍了 JVM,至于 JMM 还会再整理
面试题参考

JVM概述

什么是 JVM?

Java Virtual Machine ,java 程序的运行环境(java 二进制字节码的运行换环境)

  • jvm 是运行在操作系统之上的,与硬件没有任何关系
  • 编译之后的字节码文件和平台无关,需要在不同的操作系统上安装一个对应版本的虚拟机(JVM)

功能

  • 一次编写,到处运行(可以在不同的操作系统上运行)
  • 自动内存管理,垃圾回收功能
  • 数组下标越界检查,(防止覆盖其它代码内存)
  • 多态

常见的 JVM

JVM 是一种规范,公司可以实现自己的 jvm,介绍三大商业虚拟机

  • HotspotSun 的官网上下的基本都是这个,免费的。它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的 Java 虚拟机。
  • J9tIBM的,商用的需要和IBM的其他软件绑定,比如webSphere。
  • JRockitt:专注于服务端应用(JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行),是世界上最快的 jvm,08年被 oracle 收购

JVM 结构图

入门 JVM 这一篇就够了

运行时内存结构

内存结构主要分为五部分:

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

线程共享的:堆、方法区

线程私有的:虚拟机栈、本地方法栈、程序计数器

入门 JVM 这一篇就够了

程序计数器

Program Counter Register 程序计数器(寄存器)

  • 作用:是记住下一条 jvm 指令的执行地址
  • 特点:
    • 是线程私有的(每个线程都有自己的程序计数器)
    • 程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
    • 如果当前正在执行的方法是本地方法,那么此刻程序计数器的值为 undefined

虚拟机栈

Java Virtual Machine Stacks Java 虚拟机栈

  • 每个线程运行时所需要的内存,称为虚拟机栈。
  • 栈元素是栈帧。方法调用,栈帧入栈,反之出栈。
  • 每个线程只能有一个活动栈帧(栈顶部),对应着当前正在执行的那个方法

存放:

  • 局部变量表(方法参数、方法内的局部变量)
    • 8大基本类型
    • 对象引用(句柄引用、直接引用)
    • returnAddress类型(返回地址,并跳出函数)
  • 操作栈数
    • 局部变量表中的变量是不可直接使用的
    • 通过字节码指令将其加载至操作数栈中作为操作数使用
  • 动态链接(方法引用)
    • 每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用
    • 一部分会在类加载阶段或第一次使用的时候转化为直接引用(编译期)静态链接
    • 运行期期间转化为直接引用为动态链接
  • 出口(方法的返回地址)
    • 正常完成出口,执行引擎遇到任意一个方法返回的字节码指令
    • 异常完成出口,方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理

特点:

  • 线程私有,它的生命周期与线程相同
  • 不涉及垃圾回收(方法调用占用栈内存,但每次方法调用结束,自动弹出栈
  • 线程安全问题(考虑是否共享)
    • 如果方法内局部变量 没有逃离方法的作用访问,它是线程安全的
    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

异常:

  • 当线程请求的栈深度超过最大值,会抛出 *Error 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

本地方法栈

Native Method Stack,本地方法栈。

特点

  • 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出*ErrorOutOfMemoryError异常。

  • 本地方法栈服务的对象是 JVM 执行的 native 方法,而虚拟机栈服务的是 JVM 执行的 java 方法。

  • Native 方法指的是那些用c、c++编写的方法,因 java 代码的局限性,需要间接的调用 native 方法(java中标有native 关键字)来与操作系统的底层打交道。这些方法占用的内存就是本地方法栈。

实现思路

  • native 关键字,表示 java 作用范围达不到,会去调用底层 c 的库
  • 会进入本地方法栈(登记 native 方法),最终通过调用 JNI ,来加载本地方法库的方法
  • JNI( java Native Interface),拓展 java 的使用,融合不同的编程语言为 java 所用

Heap 堆,所有对象、数组都在这里分配内存,是垃圾收集的主要区域(“GC 堆”)。

入门 JVM 这一篇就够了

细分:(永久区/元空间单独划分到 方法区了)

  • 新生代(Young Generation)
    • 伊甸园 Eden Space
    • 幸存区S0 From
    • 幸存区S1 To
  • 老年代(Old Generation)

存放:

  • 所有引用类型的真实对象(实例),包括 类、方法、常量、变量等

特点

  • 通过 new 关键字,创建对象都会使用堆内存
  • 它是线程共享的,堆中对象都需要考虑线程安全的问题
  • 有垃圾回收机制,且是垃圾回收的主要区域(99%)
  • 一个 JVM 只有一个一个堆内存,其大小可以通过-XmxXms来控制

方法区

Method Area方法区

方法区是一种规范,元空间与永久代(1.8前)都是其的实现

所有定义的方法的信息都保存在该区域,此区域属于共享区间
入门 JVM 这一篇就够了
存放:存储每个类的结构

  • 静态变量 staic
  • 常量 final
  • 类信息(构造方法、接口定义)Class 模板
    • class文件信息包括:魔数,版本号,常量池,类,父类和接口数组,字段,方法等信息,其实类里面又包括字段和方法的信息。
  • 运行时的常量池

在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。

元空间存储类的元信息;静态变量和常量池等放入堆中。

参考知乎老歌

特点

  • 方法区是被所有线程共享

  • 在逻辑上是堆的一部分,也称非堆(Non-Heap)。

  • 提供对方法区域初始大小的控制,和堆一样不需要连续的内存,并且可以动态扩展

  • 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

  • GC 主要在 伊甸园与老年代

  • Dump:-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError

常量池

在Java的内存分配中,总共3种常量池:

(1)字符串常量池(String Constant Pool)

存放

  • 都是字符串常量
  • 1.7 以后可以存放放于堆内的字符串对象的引用(intern()方法)

特点

  • jdk1.8,将String常量池放到了堆中。(原来存在于方法区)
  • 为 HashTable 结构,长度固定,不能扩容。
  • 常量池中的字符串仅是符号,第一次用到时才变为对象,加入串池(类似懒加载)
  • 利用串池的机制,来避免重复创建字符串对象
  • 会被垃圾回收

字符串拼接

  • 变量拼接的原理是 StringBuilder (1.8)(运行期才能确定)
    • new StringBuilder,最后 toString 方法会根据拼接好的字符 new String(就在堆里面啦
    • StringBuilder 经历 init =>append=>toString
  • 常量拼接的原理是编译期优化

性能调优

  • 调整 -XX:StringTableSize=桶个数,适当提高桶个数,更好的哈希分布,减少哈希冲突
  • intern 方法,入池

(2)class常量池(Class Constant Pool)

  • 存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)
  • 每个 class文件都有一个 class常量池

(3)运行时常量池(Runtime Constant Pool)

  • 运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本
  • 运行时常量池也是每个类都有一个
  • 当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,
  • 在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

直接内存

Direct Memory,直接内存,属于操作系统内存

  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理
  • java 与 系统 都能访问

分配和回收原理

  • 使用了 Unsafe 对象完成直接内存的分配回收,
    • 使用 allocateMemorysetMemory 方法分配内存
    • 并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner (虚引用 对象)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程(守护线程)通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

禁用显示内回收对直接内存有影响

  • -xx:+DisableExplicitGC 禁用显示回收
  • System.gc() 会无效,该方法是 Full GC,很影响性能
  • 此时,可手动调用 unsafe 对象的 freeMemory 方法释放内存

内存溢出与内存泄漏

介绍概念

  • OutOfMemoryError :内存溢出,指程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于 Old 段或 Perm 段垃圾回收后,仍然无内存空间容纳新的 Java 对象的情况。参考

  • memory leak:内存泄露,指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。比如一个盘子用尽各种方法只能装4个果子,你装了5个,结果掉倒地上不能吃了。这就是溢出!

从定义上可以看出内存泄露是内存溢出的一种诱因,不是唯一因素。

内存溢出的几种情况

(1)堆内存溢出(OutOfMemoryError:java heap space)

  • 对于堆内存溢出,主要注意大量的字符串拼接操作循环中重复创建对象的问题,
  • 在一段代码内申请上百M甚至上G的内存也是一个原因
  • jvm参数:-Xms5m -Xmx5m -Xmn2m -XX:NewSize=1m

(2)方法区内存溢出(OutOfMemoryError:permgem space)

  • 如果程序加载的类过多,或者使用反射、gclib等这种动态代理生成类的技术,就可能导致该区发生内存溢出,
  • jvm参数:-XX:PermSize=2m -XX:MaxPermSize=2m

(3)栈内存溢出(java.lang.StackOveFflowError)

  • 栈帧过多导致栈内存溢出(如方法的递归调用)
  • 栈帧过大导致栈内存溢出(比较难出现吧)

内存泄露的几种场景:

  • 长生命周期的对象持有短生命周期对象的引用

    • 这是内存泄露最常见的场景,也是代码设计中经常出现的问题。
    • 例如:在全局静态map中缓存局部变量,且没有清空操作,随着时间的推移,这个map会越来越大,造成内存泄露。
  • 修改 hashset 中对象的参数值,且参数是计算哈希值的字段

    当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段,否则对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中删除当前对象,造成内存泄露。

  • 机器的连接数和关闭时间设置

    • 长时间开启非常耗费资源的连接,也会造成内存泄露。

为了避免内存泄露,在编写代码的过程中可以参考下面的建议:

  • 尽早释放无用对象的引用
  • 使用字符串处理,避免使用String,应大量使用StringBuffer,每一个String对象都得独立占用内存一块区域
  • 尽量少用静态变量,因为静态变量存放在永久代(方法区),永久代基本不参与垃圾回收
  • 避免在循环中创建对象
  • 开启大型文件或从数据库一次拿了太多的数据很容易造成内存溢出,所以在这些地方要大概计算一下数据量的最大值是多少,并且设定所需最小及最大的内存空间值。

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸

在之前的虚拟机栈 线程安全分析,也提到过

  • 如果方法内局部变量 没有逃离方法的作用访问,它是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

第一段代码中的sb就逃逸了,而第二段代码中的sb就没有逃逸。参考

public static StringBuffer craeteStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}
 
public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

一个类的分析

经过了上述知识的储备,我们可以通过下图,来回顾一下,一个类中方法与变量的内存情况
入门 JVM 这一篇就够了

  • 加载类信息到方法区(图中的成员方法、类变量等)
  • main 方法入栈(方法的参数、局部变量也存放在栈中),
  • (图中)实例化对象,在堆中 new Phone,初始化值(初始化后值在堆中),赋值,
  • (有的话)调用下一个成员方法(堆中存放的是成员方法地址,此时调用它,还需再找到方法区的)
  • 该方法入栈,类似上述操作,调用完出栈(所以不涉及垃圾回收)
  • 最后 main 方法出栈,结束

垃圾回收

如何判断对象可以回收?

  • 引用计数法:为对象添加一个引用计数器
  • 可达性分析算法:以 GC Roots 为起始点进行搜索,可达的对象都是存活的
  • 四种引用(严格上五种)
    • 强引用
      • 被强引用关联的对象不会被回收。
      • 使用 new 一个新对象的方式来创建强引用。
    • 软引用(SoftReference)
      • 被软引用关联的对象只有在内存不够的情况下才会被回收。
      • 可以配合引用队列来释放软引用自身
      • 使用 SoftReference 类来创建软引用。
    • 弱引用(WeakReference)
      • 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
      • 可以配合引用队列来释放弱引用自身
      • 使用 WeakReference 类来创建弱引用。
    • 虚引用(PhantomReference)
      • 又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
      • 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
      • 使用 PhantomReference 来创建虚引用。
    • 终结器引用(FinalReference)
      • 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象

GC Roots 一般包含以下内容:

  • 虚拟机栈中局部变量表中引用的对象
  • 本地方法栈中 JNI 中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

方法区回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

  • 主要是对常量池的回收和对类的卸载。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

垃圾回收算法

  • 标记清除(Mark Sweep):速度较快,会产生内存碎片
  • 标记整理(Mark Compact) :速度慢,没有内存碎片,涉及对象的移动,效率低
  • 复制(Copy):不会有内存碎片,需要占用双倍内存空间

内存效率(时间复杂度): 复制 > 标记清除 >标记整理

内存整齐度:复制=标记整理 > 标记清除

内存利用率:标记整理=标记清除 > 复制

没有最好的算法,但是有最合适的。

堆内分代收集:

  • 新生代 (存活率低)复制
  • 老年代 (存活率高)标记清除+标记整理 混合实现

实现过程

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的
    对象年龄加 1并且交换 from 与 to
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行(复制时涉及对象地址的移动,所以需要STW,但STW在新生代时间较短)
  • 当对象寿命超过阈值时(不同 GC 不同阈值),会晋升至老年代,最大寿命是15(4bit 的对象头)
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW 的时
    间更长

拓展

  • 大对象(新生代放不下)会放入老年代,再往上可能 OOM

  • 一个线程内oom不会导致所有的进程结束

垃圾收集器

入门 JVM 这一篇就够了

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:CMS、Serial Old、Parallel Old

整堆收集器: G1

几个概念

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

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

吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

  • Serial 收集器(串行)
    • 单线程、简单高效
    • 新生代 复制算法,老年代 标记整理算法,两个各阶段都有 STW
    • 适用于Client模式下的虚拟机。
  • ParNew 收集器(Serial 收集器的多线程版本)
    • 多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同
    • 新生代 复制算法,老年代 标记整理算法,两个各阶段都有 STW
    • 它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用
  • Parallel Scavenge 收集器(吞吐量优先收集器)
    • 与ParNew收集器类似,并行的多线程收集器。
    • 新生代 复制算法,老年代 标记整理算法,两个各阶段都有 STW
    • GC 自适应调节策略
    • 注重高吞吐量以及CPU资源敏感的场合

以上为新生代收集器

  • Serial Old 收集器(Serial收集器的老年代版本)
    • 同样是单线程收集器
    • 老年代 标记整理算法,有 STW
    • 主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
  • Parallel Old 收集器(Parallel Scavenge收集器的老年代版本)
    • 多线程
    • 老年代 标记整理算法,有 STW
    • 注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑
  • CMS (Concurrent Mark Sweep)收集器(响应时间优先)
    • 多线程,堆内存较大,多核 cpu
    • 老年代 标记清除算法,内存回收过程是与所有用户线程并发进行
    • 分为以下四个流程
      • 初始标记(需要停顿)
      • 并发标记
      • 重新标记(需要停顿)(修正并发标记期间标记产生变动的那一部分对象)
      • 并发清除
    • 适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
  • G1 (Garbage-First)收集器
    • 取代了之前的 CMS 垃圾回收器(同样有并发标记)、分代收集、空间整合
    • G1 可以直接对新生代和老年代一起回收。
    • 超大堆内存,会将堆划分为多个大小相等的 Region;整体上是标记+整理算法,两个区域之间是复制算法
    • 同时注重吞吐量(Throughput)和低延迟(Low latency),可预测的停顿(默认的暂停目标是 200 ms)

整理

  • SerialGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • ParallelGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • CMS
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足,垃圾回收速度小于垃圾产生速度,并发收集失败,退化为 Serial GC,最后调用 FullGC
  • G1
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足,垃圾回收速度小于垃圾产生速度,并发收集失败,退化为 Serial GC,最后调用 FullGC

内存分配与回收策略

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

内存分配策略

  1. 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  2. 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  3. 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,知道达到阀值对象进入老年区。(-XX:MaxTenuringThreshold 用来定义年龄的阈值)
  4. 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
  5. 空间分配担保。每次进行Minor GC时,JVM 会计算 Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。

Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

  • 调用 System.gc()
    • 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。
  • 老年代空间不足
    • 通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。
    • 还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
  • 空间分配担保失败
  • JDK 1.7 及以前的永久代空间不足
  • Concurrent Mode Failure
    • 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

类加载

类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。

类加载过程:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析参考

入门 JVM 这一篇就够了

加载

将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类。

加载是类加载的一个阶段,注意不要混淆。

加载过程完成以下三件事:

  • 通过类的完全限定名称获取定义该类的二进制字节流。
  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
  • 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

注意

如果这个类还有父类没有加载,先加载父类

加载和链接可能是交替运行

连接

又可分为三步:验证->准备->解析

验证:验证类是否符合 JVM规范,安全性检查

准备:为 static 变量分配空间,设置默认值

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成

解析:将常量池中的符号引用解析为直接引用

初始化

初始化即调用 <cinit>()V,虚拟机会保证这个类的『构造方法』的线程安全

发生的时机:概括得说,类初始化是【懒惰的】

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

不会导致类初始化的情况

  • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
  • 类对象.class 不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass 方法
  • Class.forName 的参数 2 为 false 时

小结一下类加载过程

  1. JVM 会先去方法区中找有没有相应类的.class存在。如果有,就直接使用;如果没有,则把相关类的.clss加载到方法区
  2. .class加载到方法区时,先加载父类再加载子类;先加载静态内容,再加载非静态内容
  3. 加载静态内容:
    1. .class中的所有静态内容加载到方法区下的静态区域内
    2. 静态内容加载完成之后,对所有的静态变量进行默认初始化
    3. 所有的静态变量默认初始化完成之后,再进行显式初始化
    4. 当静态区域下的所有静态变量显式初始化完后,执行静态代码块
  4. 加载非静态内容:把.class中的所有非静态变量及非静态代码块加载到方法区下的非静态区域内。
  5. 执行完之后,整个类的加载就完成了。

对于静态方法和非静态方法都是被动调用,即系统不会自动调用执行,所以用户没有调用时都不执行,主要区别在于静态方法可以直接用类名直接调用(实例化对象也可以),而非静态方法只能先实例化对象后才能调用。

这时,我们便可以得到一张,类的初始化过程图

入门 JVM 这一篇就够了

类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有 jar 包和类。

双亲委派

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型

  • 即在类加载的时候,系统会首先判断当前类是否被加载过。
  • 已经被加载的类会直接返回,否则才会尝试加载。
  • 加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 当父类加载器无法处理时,才由自己来处理。
  • 当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

入门 JVM 这一篇就够了

好处:

  • 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载( JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
  • 如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

自定义类加载器

除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,需要继承 ClassLoader

  • loadClass()实现了双亲委派模型的逻辑(取消双亲委派,就重写他)
  • 自定义类加载器一般不去重写它,但是需要重写 findClass()方法。

步骤:

  1. 继承 ClassLoader 父类
  2. 要遵从双亲委派机制,重写 findClass 方法
  3. 读取类文件的字节码
  4. 调用父类的 defineClass 方法来加载类
  5. 使用者调用该类加载器的 loadClass 方法
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Load7 {
    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader();
        Class<?> c1 = classLoader.loadClass("MapImpl1");
        Class<?> c2 = classLoader.loadClass("MapImpl1");
        System.out.println(c1 == c2);//true,加载为同一个

        MyClassLoader classLoader2 = new MyClassLoader();
        Class<?> c3 = classLoader2.loadClass("MapImpl1");
        System.out.println(c1 == c3);//false,类加载器不同,类对象不同

        c1.newInstance();
    }
}

class MyClassLoader extends ClassLoader {

    @Override // name 就是类名称
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = "e:\\myclasspath\\" + name + ".class";

        try {
            ByteArrayOutputStream os = new ByteArrayOutputStream();
            Files.copy(Paths.get(path), os);

            // 得到字节数组
            byte[] bytes = os.toByteArray();

            // byte[] -> *.class
            return defineClass(name, bytes, 0, bytes.length);

        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException("类文件未找到", e);
        }
    }
}

实例化对象

实例化对象的方式

  • 用 new 语句创建对象,这是最常见的创建对象的方法。会调用构造方法
  • 通过工厂方法返回对象,如:String str = String.valueOf(23);
  • 运用反射手段,调用 java.lang.Class 或者 java.lang.reflect.Constructor 类的newInstance()实例方法问。会调用构造方法
    • 如:Object obj = Class.forName("java.lang.Object").newInstance();
  • 调用对象的clone()方法。Object对象中存在clone方法,它的作用是创建一个对象的副本。
  • 通过 I/O 流(包括反序列答化),如运用反序列化手段,调用java.io.ObjectInputStream对象的 readObject()方法。
    • 从文件中还原对象
  • 使用 Unsafe 类创建对象
    • 反射才能拿到 Unsafe 对象
    • 拿到这个对象后,调用其中的native方法allocateInstance 创建一个对象实例
    • Object event = unsafe.allocateInstance(Test.class);

获取 class 对象

  • Class.forName("全类名"):将字节码文件加载进内存,返回Class对象
    • 多用于配置文件,将类名定义在配置文件中。读取文件,加载类
  • 类名.class:通过类名的属性class获取
    • 多用于参数的传递,最为安全可靠,程序性能最高
  • 对象.getClass()getClass()方法在Object类中定义着。
    • 多用于对象的获取字节码的方式
  • 类加载器:获取类加载器,再调用 loadClass
    • Class cl = this.getClass().getClassLoader().loadClass(“类的全类名”);

对象的形成过程

对象内存分配规则

相关参考

回顾一下,内存分配原则

  1. 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  2. 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  3. 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值对象进入老年区。
  4. 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。(无需达到阈值)
  5. 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。

对象的访问

由于 reference 类型在 Java 虚拟机规范里面只规定了一个指向对象的引用。并没有定义这个引用通过哪种方式去定位,以及访问到 Java 堆中的对象具体位置,因此不同虚拟机有不同的实现,主流有两种:

  • 使用句柄
  • 直接访问

(1)如果使用句柄,Java堆中会划分出一块内存称为句柄池,reference存放的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息

入门 JVM 这一篇就够了

(2) 如果使用直接指针访问方式,Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,reference存储的就是对象的地址:

入门 JVM 这一篇就够了

分析

这两种对象访问的方式各有优点:

  • 使用句柄访问方式最大好处就是 reference 存放的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
  • 使用直接指针访问方式的最大好处是速度更快,它节省了一次时间定位的开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。Sun HotSpot 是使用直接指针访问方式。

对象的创建过程

Java 对象由三个部分组成:

  • 对象头
    • 一部分存储对象自身的运行时数据(哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit))
    • 第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)如果是数组对象,则对象头中还有一部分用来记录数组长度。
  • 实例数据:用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)
  • 对齐填充:JVM 要求对象起始地址必须是 8 字节的整数倍(8字节对齐)

好了,我们来总结一下对象创建过程

  1. JVM 遇到一条新建对象的指令时(new 类)首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用。然后加载这个类

  2. 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,最终常用的办法“本地线程缓冲分配(TLAB)”

    1. 指针碰撞:依靠连续的内存空间,靠指针的移动来分配内存
    2. 空间列表:由固定的列表记录内存分配的信息,每一线程指定一块空间。
    3. 选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。以下介绍分配并发解决方式
    4. 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,
    5. 当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用 CAS+失败重试 进行内存分配(原子性)
  3. 除对象头外的对象内存空间初始化为 0

  4. 对 对象头进行必要设置。例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。

  5. 调用对象的 init 方法