JVM笔记(一):内存模型、对象、类加载器

145 阅读12分钟

目录

  1. 思维导图
  2. 主要内容
    • 定义
    • JVM内存模型
      • 方法区
      • 虚拟机栈
      • 本地方法栈
      • 程序计数器
    • 对象
      • 内存布局
      • 创建过程
      • 内存分配
      • 访问和定位
      • 逃逸分析
      • 对象的4种引用
    • 类加载
      • 类的生命周期
      • 类加载的过程
      • 类加载器
      • 双亲委派机制
  3. 相关面试题

1. 思维导图

JVM-内存布局.png

2. 主要内容

2.1 定义

  • Java 虚拟机,它是 Java 实现平台无关性的基石。 Java 程序运行的时候,编译器将 Java 文件编译成平台无关的 Java 字节码文件(.class), 对应平台 JVM 对字节码文件进行解释,翻译成对应平台匹配的机器指令并运行。

2.2 JVM内存模型

  • 2.2.1 堆
    • 线程共享的区域,存放对象实例
    • 堆分为Young Generation(新生代)和Old G renovation(老年代)
    • Young Generation - Eden / From Survivor / To Survivor - 新生代回收时,一般是把存活对象全都复制到一块Survivo区中,然后把Eden和另一块Survivor区的对象全部清空,保证得到连续的可用内存,Survivor区放不下的就会使用空间担保直接进入老年代的内存,因为JVM认为一般大对象的存 活时间一般比较久。
  • 2.2.2 虚拟机栈
    • 线程私有,生命周期与线程相同
    • 方法执行时,JVM 会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态连接等。
  • 2.2.3 本地方法栈
    • 与虚拟机栈所发挥的作用相似,其区别是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。
  • 2.2.4 程序计数器
    • 线程所执行的字节码的行号指示器
  • 2.2.5 方法区
    • 各个线程共享的内存区域,存储常量池和运行时常量池数据。
    • 使用元空间(1.8)替代永久代(1.7)作为方法区实现的原因 - 永久代无论配置与否都有一个内存上限,更容易出现内存溢出问题;元空间是用的本地内存,只要没达到进程可用内存上限就没问题(32位系统是2^32,4GB,64位系统是2^64,256TB) - Oracle想要把JRockit上的优秀功能移植到Hotspot上, JRockit是用元空间本地内存的方式实现方法区,因此永久代的实现是个移植的阻碍

2.3 对象

2.3.1 对象内存布局

  • 对象头(Header)
    • 对象自身运行时数据(Mark Word): 哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳
    • 类型指针: 对象的类元数据类型(即对象属于哪个类)。
    • 如果是Java数组,记录数组长度的数据
  • 实例数据(Instance Data)
    • 对象的字段
  • 对齐填充(Padding)

2.3.2 对象创建

  1. 检查常量池中能否定位到类的符号引用
  2. 检查类的符号引用是否已经被加载、解析、初始化过,没有则先执行类加载
  3. 虚拟机为对象分配内存
  4. 虚拟机将分配到的内存空间(除对象头)都初始化为零值
  5. 设置对象头,请求头里包含了对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。

2.3.3 对象内存分配

  • 指针碰撞 假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被 放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空 间方向挪动一段与对象大小相等的距离
  • 空闲列表 如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起, 那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的, 在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
  • 两种方式的选择由 Java 堆是否规整决定,Java 堆是否规整是由选择的垃圾收集器是否具有压缩 整理能力决定的
  • 创建对象时堆内存抢占的问题
    1. 移动指针时使用CAS保证原子性
    2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB): 堆上给每个线程预分配内存,创建对象只在线程分配到的内存上,线程分配内存满了才同步申请新的缓冲内存

2.3.4 对象的访问和定位

  • Java 程序会通过栈上的 reference 数据来操作堆上的具体对象
  • 句柄访问: 堆中划分出一块内存来作为句柄池,reference 中存储的是对象的句柄地址,而句柄中分别包含了对象实例数据地址与类型数据地址
    • 优点: reference 中存储的是稳定句柄地 址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
  • 直接指针访问: reference 中存储的直接就是对象地址,如果访问对象类型数据,则从对象地址的对象类型指针跳转对象类型数据
    • 优点: 速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
  • HotSpot 虚拟机主要使用直接指针来进行对象访问。

2.3.5 逃逸分析

  • 定义: 在编译期间,JIT 会对代码做的一种优化,目的是减少内存堆分配压力。这种优化分析指针动态范围,当变量 (或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所 引用,这种现象称作指针(或者引用)的逃逸(Escape)。如果对象还有可能被外部线程访问到,例如赋值给可以在其它线程中访问的实例变量,这种被称为线程逃逸。
  • 逃逸强度:不逃逸<方法逃逸<线程逃逸
  • 逃逸分析的好处
    1. 栈上分配: 如果确定一个对象不会逃逸到线程之外,那么久可以考虑将这个对象在栈上分配,对象占用的内存随着 栈帧出栈而销毁,这样一来,垃圾收集的压力就降低很多。
    2. 同步消除: 线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线 程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除 掉。
    3. 标量替换: 如果一个数据是基本数据类型,不可拆分,它就被称之为标量。把一个 Java 对象拆散,将其用到的成 员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法 外部访问,并且这个对象可以被拆散,那么可以不创建对象,直接用创建若干个成员变量代替,可以让 对象的成员变量在栈上分配和读写。

2.3.6 对象的4种引用

  • 强引用(Strongly Reference): 代码之中普遍存在的引用赋值,任何情况下,只 要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用(Soft Reference): 用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常
  • 弱引用(Weak Reference): 描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能 生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只 被弱引用关联的对象
  • 虚引用(Phantom Reference): 最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置 虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

2.4 类加载

2.4.1 类的生命周期

  • 一个类从被加载到虚拟机内存中开始,到从内存中卸载,整个生命周期需要经过七个阶段:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading),其中验证、准备、解析三个部分统称为连 接(Linking)

2.4.2 类加载的过程

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

2.4.3 类加载器

  • 启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。
  • 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供 一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  • 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。
  • 用户自定义类加载器 (user class loader),用户通过继承 java.lang.ClassLoader 类的方式自行 实现的类加载器。

2.4.4 双亲委派机制

2.4.4.1 双亲委派模型的工作过程
  • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求 最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载
2.4.4.2 使用双亲委派机制的原因
  • 为了保证应用程序的稳定有序: 例如类 java.lang.Object,它存放在 rt.jar 之中,通过双亲委派机制,保证最终都是委派给处于模型 最顶端的启动类加载器进行加载,保证 Object 的一致。反之,都由各个类加载器自行去加载的话,如 果用户自己也编写了一个名为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中就会出 现多个不同的 Object 类。
2.4.4.4 实现一个热部署功能
  • 目的: 将 java 类卸载,并且替换更新版本的 java 类
  • 已知: 类的加载都是由系统自带的类加载器完成,类加载器读取 class 字节码,再将类转化为实例, 对实例 newInstance 就可以生成对象。对于同一个全限定名的 java 类,只能被加载一次,而且无法被卸载
  • 思路: 销毁类加载器,然后重新加载
    1. 自定义类加载器,并重写 ClassLoader 的 findClass 方法
    2. 销毁原来的自定义 ClassLoader
    3. 更新 class 类文件
    4. 创建新的 ClassLoader 去加载更新后的 class 类文件。
2.4.4.5 Tomcat 的类加载机制

Tomact 是 web 容器,可能需要部署多个应用程序,对于类加载的需求是:

  1. 不同的应用程序可能会依赖同一个第三方类库的 不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的.如果采用默认的双亲委派类加载机制,那么无法加载多个相同的类。
  2. 同一个web容器中相同的类库相同的版本可以共享,避免同一个类重复加载进虚拟机
  3. 容器自身依赖的库和应用程序的类库不能混淆,需要隔离
  4. 容器不重启支持JSP的修改,见上面热部署功能的内容

Tomcat的实现如下

TOMCAT.png

  1. CommonClassLoader能加载的类都可以被Catalina ClassLoader和Shared ClassLoader使用,从而实现了公有类库的共用,而Catalina ClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。
  2. WebApp ClassLoader可以使用Shared ClassLoader加载到的类,但各个WebApp ClassLoader实例之间相互隔离。

3. 相关面试题

  • 什么是 JVM
  • JVM 的内存模型以及分区,每个区放什么
  • 说一下1.7、1.8 内存区域的变化 为什么使用元空间替代永久代作为方法区的实现?
  • 堆里面的分区:Eden,survival (from+ to),老年代,各自的特点。
  • 对象创建过程
  • 对象内存分配
  • JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安 全的?
  • 对象的访问定位
  • 对象内存布局
  • 对象有几种引用
  • 类的生命周期
  • 类加载的过程
  • 类加载器有哪些
  • 介绍双亲委派机制
  • 为什么要用双亲委派
  • 如何破坏双亲委派
  • Tomcat的类加载机制