天天看点

JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:

JVM 深入理解 - 对象 “对象”?

  • 对象
  • JVM 中对象的创建过程
    • 检查加载
    • 分配内存
      • 并发安全
    • 内存空间初始化
    • 对象
      • 对象初始化
      • 对象的内存布局
      • 对象的访问定位
        • 句柄
        • 直接指针
      • 判断对象存活
        • 引用计数法
        • 可达性分析
      • Finalize 方法
  • 引用
    • 强引用
    • 软引用
    • 弱引用
    • 虚引用
  • 对象的分配策略
    • 栈上分配
      • 逃逸分析
        • 状态
      • 同步省略(锁消除)
      • 标量替换
        • 标量替换并不成熟
    • 对象优先在 Eden 区分配
    • 大对象直接进入老年代
    • 长期存活对象进入老年区
    • 对象年龄动态判定
    • 空间分配担保
  • 总结:

Java 学习目录

上一章 JVM 深入理解 - 内存溢出

本章节将讲述四个关键点

一、对象的创建过程

二、对象的组成

三、对象的引用

四、对象分配内存的细节

对象

Java 世界万物皆对象。没有对象么? new 一个。

JVM 中对象的创建过程

JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:

虚拟机遇到一条 new 指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。类加载就是把 class 加载到 JVM 的运行时数据区的过程。

检查加载

首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用( 符号引用 :符号引用以一组符号来描述所引用的目标 ),并且检查类是否已经被加载、解析和初始化过。

分配内存

  • 指针碰撞
    • 释义:首先将 JVM 堆空间视为一个绝对规整的空间,所有使用过的内存都放在一边,而空闲出来的空间都在另一边。这两个空间中间由一个指针分隔,作为分界点。当需要分配内存的时候就将指针向空闲的一边移动对象所需大小相等的距离即可。
    • 垃圾回收器:

      用于Serial和ParNew等不会产生内存碎片的垃圾收集器。

    • 图示
      JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:

      红色方块:已使用的空间

      绿色方块:未使用的空间

      黑色箭头:指针

  • 空闲列表
    • 释义:如果 JVM 堆中的内存并不是规整的,使用与未使用的内存,在内存空间交错出现,那就没有办法使用简单的指针碰撞,来完成内存分配的任务,JVM 需要通过维护一张 内存列表 来记录可用内存信息,当需要分配内存的时候从空闲列表中,找到一块足够大的空间划分给对象实例,并更新列表上的记录。
    • 垃圾回收器:

      最常见的使用此方案的垃圾收集器就是 CMS 。

    • 图示
      JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:

      红色方块:已使用的空间

      绿色方块:未使用的空间

      黑色箭头:指针

  • 小结: 选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因为垃圾回收器决定了堆空间的使用与分配。

并发安全

上边我们讲述了 JVM 如何分配内存,但是创建对象在 JVM 中是一个非常频繁的操作,仅仅修改一个指针自指向的位置(或修改空闲列表),在并发情况下并不能保证并发安全,可能会出现现实生活中,一女许二夫的情况发生,所以又引来了下面两种解决方案。

  • CAS 机制

    对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;

  • 分配缓冲 TLAB
    • Thread Local Allocation Buffer,TLAB ,JVM 为每一个线程在初始化的时候申请一个指定大小的空间,并且只有该线程可以使用,这样每一个线程都有一个缓冲区,如果需要分配内存就直接在缓冲区上进行分配,就可以避免内存竞争的情况,提高内存分配效率,当缓冲区不够的时候,在重新从 Eden 区域中在重新申请一块内存空间。
    • TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB。
    • 参数设置

      -XX:+UseTLAB

      允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用 TLAB,请指定-XX:-UseTLAB。

内存空间初始化

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

  • 设置

    接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在 Java hotspot VM 内部表示为类元数据)、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。

对象

对象初始化

完成以上步骤后,一个对象已经产生,但是对于 Java 程序员来说对象的创建还缺少对象的初始化。也就是我们的构造方法的执行,执行完毕后,一个对象的就算生产完毕。

对象的内存布局

JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:
  • 对象
    • 对象头
      • 运行时数据 Mark Word
        • 哈希码
        • GC 分代年龄
        • 锁状态标识
        • 线程持有的锁
        • 偏向线程ID
        • 偏向时间戳
      • 类型指针
      • 数组长度
    • 实例数据
    • 对齐填充

对象的访问定位

当我们在 JVM 中创建了一个对象的时候,我们的目的是使用这个对象,但是定位的工作是通过栈上的 refrence 数据来操作对上的具体对象,访问方式有两种。

句柄

JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:
  • 对象 类型 数据的指针
  • 对象 实例 数据的指针

在 JVM 堆中划分一块内存空间作为句柄池,通过 reference 指向到句柄池中的句柄对象,然后句柄中包含有对象示例数据的指针,和对象类型数据的指针。

  • 优点

    当使用 GC 进行垃圾回收的时候,在需要需要移动对象,就只需要改变句柄中的示例数据指针就可以,而 reference 则不需要修改。

    例如在使用各种包含了整理的垃圾回收器算法。(后便会说到各种垃圾回收算法)

直接指针

JVM 深入理解 - 对象 “对象”?对象JVM 中对象的创建过程引用对象的分配策略总结:

通过 reference 存储实例数据的地址指针,直接就可以访问当示例数据。在实例数据中,有指针指向对象的类型数据指针。

  • 优点

    使用指针的好处就是访问速度更快,因为只许一次指针定位的时间开销,由于Java 的内存访问定位非常频繁,所以积少成多后提升的整体运行速度提升很多。

  • 备注

    由于使用了指针方式。所以在 GC 整理内存区域的时候需要修改 reference 中的指针。

判断对象存活

在 JVM 中内存区域是不需要我们去维护的,这也是 Java 最引以为傲的地方,但是那些对象是在使用的,那些对象已经无人引用。在 GC 进行垃圾回收之前,需要先知道那些对象使存活的,那些是已经死亡的,如何判断,就需要一下两种方式来进行判断。

对比其他语言获取内存的方式

  • C
    • 申请 :malloc
    • 释放 :free
  • C++
    • 申请 :new
    • 释放 :delete
  • Java
    • 申请:new
    • 释放:系统自动回收。

      为什么说自动回收是 Java 引以为傲的地方,手动回收会有那些问题?

      手动回收容易出现以下两种问题。

  • 忘记回收

    在忘记回收内存后会造成内存泄漏,内存不可控,无人管理的问题。

  • 多次回收

    步骤:

  1. T1:回收内存
  2. T2:创建对象
  3. T1:回收内存
  4. T2:使用对象

    在这种情况下会出现刚刚创建的对象竟然找不到了,抛出了空指针问题。因为在多线程情况下,计算机的运算速度又非常快,这种情况会经常发生。

引用计数法

在使用引用计数法的时候,在对象中需要添加一个引用计数器。每当有一个对象引用这个对象,那么引用计数器就会 +1 ,当引用取消的时候,计数器 -1。

  • 缺点:

    可能会出现 “互相引用” 问题。

    • 即 A 对象引用 B 对象,同时 B 对象引用 A 对象。

这种情况的出现,无法避免,AB在互相引用的同时,有没有别的对象在引用,所以也就造成了,这两个对象互相引用,在引用计数法的认定时,他是一个存活对象,但它却是没有任何对象在使用他们,所以需要额外的处理机制来处理,会影响效率。

  • 备注:

    主流虚拟机并没有使用引用计数法。

可达性分析

可达性分析又叫根可达分析,那么“根” 是什么呢??

所谓的 “根” 也就是 GC Roots ,GC 在回收对象的时候会通过 GC Roots 作为对象的起点,然后从这些节点向下寻找,这些所搜节点所走过的路径,就叫做引用连。当一个对象到 GC Roots 没有任何引用链时,证明对象不可用。

GC Roots 包含下面几种

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个现场被调用方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象;java 类的引用类型静态变量。
  • 方法区中常量引用的对象;比如:字符串常量池里的引用。
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
  • JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。(非重点)
  • 所有被同步锁(synchronized 关键)持有的对象。(非重点)
  • JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等(非重点)
  • JVM 实现中的“临时性”对象,跨代引用的对象( 在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念)(非重点)

    以上的回收都是对象,类的回收条件。

Finalize 方法

注:

  • 不建议重写 finalize 方法,因为会造成假死,影响 GC 性能。
  • 执行时间不定,不可控。
  • finalize 方法只在第一次 GC 的时候执行该方法,之后就不在执行 finalize 方法了。

finalize 方法是在 GC 回收内存空间之前去执行的一个方法,如果该方法是被重写过,则执行方法代码,执行完毕后将进行 GC 垃圾回收,但是如果在方法代码执行中,有造成了本类可被扫描到,则会造成 GC 回收失败。

引用

强引用

  • 定义方式:使用等号赋值就是强引用
  • 例:Object obj = new Object()
  • 回收规则:永远不会被回收。

软引用

  • 定义方式: SoftReference
  • 例:UserInfo u = new UserInfo(1,“King”); //new是强引用

    SoftReference < UserInfo > userSoft = new SoftReference < UserInfo > (u);//软引用

    u = null; // 干掉强引用,确保这个实例只有userSoft的软引用

  • 回收规则:JVM 将要发生内存溢出时,这个对象将会被回收。
  • 实例代码:
import java.lang.ref.SoftReference;
import java.util.LinkedList;
import java.util.List;

/**
 * -Xms20m -Xmx20m
 */
public class TestSoftReference {
	// 对象
	public static class UserInfo {
		public int id = 0;
		public String username = "";
		public UserInfo(int id, String username) {
			super();
			this.id = id;
			this.username = username;
		}
		@Override
		public String toString() {
			return "UserInfo [id=" + id + ", username=" + username + "]";
		}
	}
	public static void main(String[] args) {
		UserInfo u = new UserInfo(1, "吴勉"); // new 是强引用
		SoftReference<UserInfo> userSoft = new SoftReference<UserInfo>(u);// 软引用
		u = null; // 去掉强引用,确保这个实例只有 userSoft 的软引用
		System.out.println(userSoft.get()); // 看一下这个对象是否还在
		System.gc();// 进行一次GC垃圾回收 千万不要写在业务代码中。
		System.out.println("After gc");
		System.out.println(userSoft.get());
		// 往堆中填充数据,导致OOM
		List<byte[]> list = new LinkedList<byte[]>();
		try {
			for (int i = 0; i < 100; i++) {
				list.add(new byte[1024 * 1024 * 1]); // 1M的对象 100m
			}
		} catch (Throwable e) {
			// 抛出了OOM异常时打印软引用对象
			System.out.println("Exception--------------" + userSoft.get());
		}
	}
}
           

弱引用

  • 定义方式: WeakReference
  • 例:User u = new User(1,“King”); //new是强引用

    WeakReference userSoft = new WeakReference(u);//软引用

    u = null; // 干掉强引用,确保这个实例只有userSoft的软引用

  • 回收规则:只要有GC 就会被回收。
  • 实例代码:
public class TestWeakRef {
	public static class UserInfo {
		public int id = 0;
		public String username = "";
		public UserInfo(int id, String username) {
			super();
			this.id = id;
			this.username = username;
		}
		@Override
		public String toString() {
			return "User [id=" + id + ", name=" + username + "]";
		}
	}
	public static void main(String[] args) {
		UserInfo u = new UserInfo(1, "吴勉");
		WeakReference<UserInfo> userWeak = new WeakReference<UserInfo>(u);
		u = null;// 干掉强引用,确保这个实例只有userWeak的弱引用
		System.out.println(userWeak.get());
		System.gc();// 进行一次GC垃圾回收,千万不要写在业务代码中。
		System.out.println("After gc");
		System.out.println(userWeak.get());
	}
}
           

虚引用

  • 定义:深入理解JAVA虚拟机一书中有这样一句描述:“为一个对象zhi设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知”。所以虚引用更多的是用于对象回收的监听。
  • 定义方式:PhantomReference
  • 例:
// 伪代码
    private static final ReferenceQueue<UserInfo> QUEUE = new ReferenceQueue<UserInfo>();
	static List<byte[]> list = new LinkedList<byte[]>();
	UserInfo u = new UserInfo(1, "吴勉"); // new是强引用
	PhantomReference<UserInfo> userSoft = new PhantomReference<UserInfo>(u, QUEUE);// 虚
           
  • 回收规则:幽灵引用,最弱(随时会被回收掉)
  • 主要的使用场景如下:
    1. 重要对象回收监听 进行日志统计
    2. 系统 GC 监听因为虚引用每次 GC 都会被回收,我们就可以通过虚引用来判断 GC 的频率,如果频率过大,内存使用可能存在问题,才导致了系统 GC 频繁调用。
public class TestPhantomReference {
	// 对象
	public static class UserInfo {
		public int id = 0;
		public String username = "";
		public UserInfo(int id, String username) {
			super();
			this.id = id;
			this.username = username;
		}
		@Override
		public String toString() {
			return "UserInfo [id=" + id + ", username=" + username + "]";
		}
	}
	private static final ReferenceQueue<UserInfo> QUEUE = new ReferenceQueue<UserInfo>();
	static List<byte[]> list = new LinkedList<byte[]>();
	public static void main(String[] args) {
		UserInfo u = new UserInfo(1, "吴勉"); // new是强引用
		PhantomReference<UserInfo> userSoft = new PhantomReference<UserInfo>(u, QUEUE);// 虚引用
		Alive();
		userSoft.get();
		u = null; 
		Alive();
		System.gc();// 进行一次GC垃圾回收 千万不要写在业务代码中。
		Alive();
	}
	public static void Alive() {
		Reference<? extends UserInfo> poll = QUEUE.poll();
        if (poll != null) {
            System.out.println("--- 虚引用对象被jvm回收了 ---- " + poll);
            System.out.println("--- 回收对象 ---- " + poll.get());
        } else {
        	System.out.println("--- 虚引用对象还活着 ---- " + poll);
		}
	}
}
           

对象的分配策略

对象可以分配对象的地方通常只存放在堆中,但在开启了逃逸分析的时候就有更多的选择。

开启了逃逸分析后,对象的分配方式如下

  • 标量替换

栈上分配

对象可以再栈上分配,但是要满足的前提是方法中的对象没有逃逸。那么什么是逃逸呢?

解释:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

逃逸分析

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。

Java Hotspot 编译器实现下面论文中描述的逃逸算法:

[Choi99] Jong-Deok Choi, Manish Gupta, Mauricio Seffano, Vugranam C. Sreedhar, Sam Midkiff,

“Escape Analysis for Java”, Procedings of ACM SIGPLAN OOPSLA Conference, November 1, 1999

根据 Jong-Deok Choi, Manish Gupta, Mauricio Seffano,Vugranam C. Sreedhar, Sam Midkiff 等大牛在论文《Escape Analysis for Java》中描述的算法进行逃逸分析的。

该算法引入了连通图,用连通图来构建对象和对象引用之间的可达性关系,并在次基础上,提出一种组合数据流分析法。

由于算法是上下文相关和流敏感的,并且模拟了对象任意层次的嵌套关系,所以分析精度较高,只是运行时间和内存消耗相对较大。

状态

  • 全局逃逸(GlobalEscape)

    即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:

  1. 对象是一个静态变量
  2. 对象是一个已经发生逃逸的对象
  3. 对象作为当前方法的返回值
  • 参数逃逸(ArgEscape)

    如果一个对象被作为参数传递给一个方法,但是在这个方法之外无法访问或者对其他线程不可见,这个对象标记为参数级别逃逸。

  • 没有逃逸

    即方法中的对象没有发生逃逸。

  • 实例代码
public class Escape {
	public class abc{}
	public static abc b;
	public void a() {
		abc a = new abc();  // 无逃逸
	}
	public void b() {
		b = new abc();      // 全局逃逸
	}
	public abc c() {
		return new abc();   // 全局逃逸
	}
	public void d() {
		e(new abc());       // 参数逃逸
	}
	public void e(abc a) {
		System.out.println(a);
	}
}
           
  • 设置:
  • -XX:+DoEscapeAnalysis 开启逃逸分析
  • -XX:-DoEscapeAnalysis 关闭逃逸分析
  • -XX:+PrintEscapeAnalysis 显示分析结果
  • 其他详细参数配置 JVM 配置说明

    事实上逃逸分析可以对JVM 有更多的助益。例如标量替换,同步省略(锁消除)。

同步省略(锁消除)

在动态编译同步块(synchronized)的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除。

示例:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}
           

JIT 分析后

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}
           
  • 设置:
  • -XX:+EliminateLocks 开启锁消除
  • -XX:-EliminateLocks 关闭锁消除

标量替换

定义:

  • 标量

    基础类型 和 对象的引用 可以理解为标量,它们不能被进一步分解。

  • 聚合量

    能被进一步分解的量就是聚合量,比如 对象。

  • 对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。

替换前

public class replace {
	public static void main(String[] args) {
		X x = new X(1);
		System.out.println("x :" + x.x );
	}
}
public class X {
	public int x;
	public X(int x) {
		this.x = x;
	}
}
           

替换后

public class replace {
	public static void main(String[] args) {
		int x = 1;
		System.out.println("x :" + x.x );
	}
}
           

设置:

  • -XX:+EliminateAllocations 开启标量替换
  • -XX:-EliminateAllocations 关闭标量替换
  • -XX:+PrintEliminateAllocations 显示标量替换详情

标量替换并不成熟

其原因就是 无法保证 逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

对象优先在 Eden 区分配

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

大对象直接进入老年代

  • 大对象:

    大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

长期存活对象进入老年区

  • 长期存活:

    对象头 → Mark Word → GC 年龄分代 → 大于 15 次。

  • HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。

    为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中.

  • GC 年龄分代的增加

    如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15),CMS 是 6 时,就会被晋升到老年代中。

  • 设置
    • -XX:MaxTenuringThreshold

对象年龄动态判定

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

空间分配担保

在进行 Minor GC 前虚拟机会进行一系列的动作。

  1. 判断老年代最大的连续空间是否大于新生代所有对象空间总和,是:去4 否:去2
  2. 判断 HandlePromotionFailure 是否允许担保失败,是:去3 否:去5
  3. 判断老年代 最大 可用 连续空间,是否大于历次晋升到老年代对象的平均大小,是:去4 否:去 5
  4. 进行 Minor GC ,失败:去 5
  5. 进行 Full GC 。

总结:

  1. 在本章节我们了解了对象的组成。
  • 对象
    • 对象头
      • 运行时数据 Mark Word
        • 哈希码
        • GC 分代年龄
        • 锁状态标识
        • 线程持有的锁
        • 偏向线程ID
        • 偏向时间戳
      • 类型指针
      • 数组长度
    • 实例数据
    • 对齐填充
  1. 对象的引用
  • 强引用
  • 软引用
  • 弱引用
  • 虚引用
  1. 以及对象分配内存的一些细节。

    栈上分配、逃逸分析、标量替换、同步省略(锁取消)、堆分配、CAS 和 TLAB

    Java 学习目录