jvm调优三:对象创建和内存分配机制

对象的创建

对象创建流程

对象主要的创建流程
在这里插入图片描述
类加载检查
jvm遇到创建指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用是否被验证、准备、解析、初始化。如果没有执行类加载过程(在jvm调优一讲过)。
创建指令对应到语言层面上是,new关键词、对象克隆、对象序列化等。
分配内存
通过类加载检查后,jvm就开是为对象分配内存空间。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。那么这里有两个问题。

  1. jvm如何划分内存空间
  2. jvm如何解决并发安全问题(多个线程对象对同一空间的争抢)

内存划分方法:

  1. 指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
  2. 空闲列表:如果Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个空闲列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新空闲列表。

默认采用指针碰撞的方法分配空间,具体还需要看使用的是那种垃圾回收算法,这个后面再说。
解决并发问题的方法:

  1. CAS(compare and swap):虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。具体CAS是如何保证并发,这个在写到并发专题的时候在说。
  2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):就是说先在堆中划分一块内存给每个线程。如果线程要创建对象,就在对应线程内存中创建。比如说事先给线程A在堆中划分了一块内存空间A,那么线程A要创建对象了,就在这个块A空间中创建。如果A空间满了,就在A空间以外的,没有给其他线程分配的空间中创建对象,同时采用CAS的方式来处理并发问题。
    通过­XX:+/­ UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启­XX:+UseTLAB),­XX:TLABSize 指定TLAB大小。

初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
类加载过程也有一个初始化,这两个有什么不同?
说白了就是类加载的初始化给静态变量赋初始值对象创建的初始化给成员变量赋初始值

设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对 象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data) 和对齐填充(Padding)。
在这里插入图片描述
实例数据就不多说了。
对齐填充:保证对象大小是8个字节的整数倍。这个涉及到计算机底层,这里不做说明
重点来说说对象头。
对象头(Object Header) :包含Mark work标记字段、Klass Pointer 类型指针、数组长度。先来说说Mark Word 标记

  • Mark Work 标记字段 :在32位系统中占4个字节,在64位系统中占8个字节。其中包含锁状态、哈希值、GC分带年龄、线程持有锁、锁标志位等等。如上图。
  • Klass Pointer 类型指针:之前说过类的元数据信息是放在方法区的,jvm就是靠着这个类型指针将堆里面的对象指向方法区的元数据。C++需要调用某个类的方法时,或者说jvm怎么知道这个对象是属于那个类型的靠的就是这个类型指针。举个例子来说,有个Order.java 这个类,在类加载后Order.class 元数据信息就被加载到了方法区。当执行了new 方法时,堆中就有Order这个对象,它的对象头中的Klass Pointer就是指向方法区的Order.class的元数据信息。
    注意:有些时候把类型指针当成类对象,比如Class<Order> clazz的order类的类对象。这是不正确的。我们通过反射,利用类对象获取类的信息,比如方法、变量等,是从java层面,跟类型指针没关系。类型指针是jvm也就是C++底层需要知道Order.java 是属于Order.class时,需要用到这个类型指针。
  • 数组长度:这个是数组对象才有的。

关于类型指针还牵扯到指针压缩的概念。这里不展开描述,这部分留到番外篇的时候在写吧。
执行<init>方法
当设置完对象头后,执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意这时候的赋值就是赋予真正的值,这是由程序员赋的值),和执行构造方法。<init>方法是C++底层调用的。如下图
在这里插入图片描述

对象内存分配

对象内存分配流程图
在这里插入图片描述

栈内分配

我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的 内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

逃逸分析

分析对象的作用域,如果当前对象只在当前方法中有调用,其他地方没有调用,说明该对象没有逃逸出当前方法。那么就符合在栈内分配。如果当前对象不仅在当前方法中有调用,在其他地方也有调用,那么这个对象就逃逸出当前方法。不符合栈内分配。
举个例子:

package test;

public class test1 {
	public User addUser1(){
		User user = new User();
		user.setId("11111");
		user.setName("aaaa");
		return user;
	}

	public void addUser2(){
		User user = new User();
		user.setId("11111");
		user.setName("aaaa");
		System.out.println("执行插入数据库操作");
	}

}

class User {
	private String id;
	private String name;
	public String getId() {
		return id;
	}
	public void setId(String id) {
		this.id = id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
}

如上面的代码,addUser1返回了user对象,说明其他地方可以通过addUser1使用到User对象,说明这个user对象逃逸出了addUser1,因此不能再栈内分配。addUser2内部创建了User对象,这个user对象只能在addUser2内部使用,其他地方不能使用到,因此addUser2创建的user对象没有逃逸出addUser2,符合在栈内分配的情况。
除了满足逃逸分析外,还要看这个对象是否在栈内有足够的空间进行分配,如果没有任然不能再栈内分配。在这种情况下还有标量替换的概念

标量替换

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。
标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一 步分解的聚合量。
怎么理解这个标量替换
在上一篇jvm调优中有说过,栈内有栈帧、程序计数器,栈帧里面又有成员变量表、操作数栈、动态链接、方法出口等。这些都占据这线程栈的空间。所以说栈的空间不一定是连续的,可能这块区域有一些没用的空间,另一块区域又有没有的空间,但是这些空间不是连在一起的。我们知道对象分配是需要一块连续的满足大小的空间。因此通过这个标量替换代替了对象的创建。将对象进一步的分解一个个的标量。然后这些标量就可以在不连续的空间中存放。

栈内分配的案例和参数

在JDK7后默认开启逃逸分析和标量替换,当然也能设置参数开启或关闭。
开启逃逸分析:-XX:+DoEscapeAnalysis
关闭逃逸分析:-XX:-DoEscapeAnalysis
开启变量替换:-XX:+EliminateAllocations
关闭标量替换:-XX:-EliminateAllocations
案例:

package common;

import entity.User;

public class AllotOnStack {
	/**
	 * 栈上分配,标量替换
	 * 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。
	 * 使用如下参数不会或少量发生GC
	 * ‐Xmx15m ‐Xms15m ‐XX:+DoEscapeAnalysis ‐XX:+PrintGC ‐XX:+EliminateAllocations
	 * 使用如下参数都会发生大量GC
	 * ‐Xmx15m ‐Xms15m ‐XX:‐DoEscapeAnalysis ‐XX:+PrintGC ‐XX:+EliminateAllocations
	 * ‐Xmx15m ‐Xms15m ‐XX:+DoEscapeAnalysis ‐XX:+PrintGC ‐XX:‐EliminateAllocations
	 */

	private static void alloc() {
		User user = new User();
		user.setId("111");
		user.setName("aaa");
	}

	public static void main(String[] args) {
		long start = System.currentTimeMillis();
		for (int i = 0; i < 100000000; i++) {
			alloc();
		}
		long end = System.currentTimeMillis();
		System.out.println(end - start);
	}
}

因为在JDK7后默认开启逃逸分析和标量替换,我们在jvm参数上添加:-Xmx15M -Xms15M -XX:+PrintGC
在这里插入图片描述
运行结果:没有发生GC
在这里插入图片描述
在jvm参数上添加:-Xmx15M -Xms15M -XX:+PrintGC -XX:-DoEscapeAnalysis,关闭逃逸分析
在这里插入图片描述
运行结果:大量GC
在这里插入图片描述
在jvm参数上添加:-Xmx15M -Xms15M -XX:+PrintGC -XX:-EliminateAllocations,关闭标量替换
在这里插入图片描述
运行结果:
在这里插入图片描述
上面案例说明了逃逸分析和标量分配还是很有必要,不过这些不需要我们去设置,JDK1.7后默认都开启了,了解这么回事就好。

堆内分配

在逃逸分析失败后,或者大对象导致不能再栈上分配,大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回 收,把剩余存活的对象一次性挪动到另外一块为空的survivor区。因为新生代的对象都是朝生夕死的,存活时间很短,所 以JVM默认的Eden 和survivor的比值8:1:1是很合适的,让eden区尽量的大,survivor区够用即可,JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
大对象直接进入老年代
如果创建的对象是大对象,那么就会直接在老年代上分配空间。大对象就是需要大量连续内存空间的对象(比如:字符串、数组)
JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下 有效。
比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,超过这个大小的对象就会进入老年代。为什么要这样设计,为了避免为大对象分配内存时的复制操作而降低效率。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在 老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代 的年龄阈值,可以通过参数-XX:MaxTenuringThreshold 来设置。
对象动态年龄判断
当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会 把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。
老年代空间分配担保机制
年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) 就会看-XX:-HandlePromotionFailure(jdk1.8默认就设置了)是否设置了,如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果结果是小于或者之前说的参数没有设置,那么就会先触发一次Full gc,在触发minor gc, 如果回收完还是没有足够空间存放新的对象就会发生"OOM"。当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”。如下图:
在这里插入图片描述
说的在直白一点就是,每次minor gc之前,jvm判断老年代可用空间如果小于年轻代当前对象的总和,看-XX:-HandlePromotionFailure是否配置,如果没有配置,就触发full gc,再触发minor gc。如果配置了呢,比如每次年轻代minor gc后放入老年代的对象是10M左右,那么根据以往的经验,jvm就会判断在触发minor gc之后,放入到老年代的对象的平均值是10M。那么如果老年代的空间大于了10M就不会触发full gc,如果小于了10M就会先触发full gc。

对象内存分配总结

根据对象内存分配流程图总结一下。
jvm接受到创建命令时,比如说new关键词、对象克隆、对象序列化,首先进行类加载检查通过后开始在内存中为对象分配空间。

  1. 进行逃逸性分析,如果满足并且对象大小满足栈的可用空间,那么通过标量替换在栈内创建。
  2. 不满足逃逸性分析,如果设置了-XX:PretenureSizeThreshold=3072 -XX:+UseSerialGC`。那么大于3kb就属于大对象,如果是大对象直接进入老年代
  3. 如果不是大对象,新生代Eden中分配空间,如果处于并发中首先通过本地线程分配缓冲区(TLAB),判断当前TLAB可用空间是否能分配空间,能则分配,不能在Eden的其他空间中通过CAS处理并发在分配。
  4. 在分配的过程中如果堆的内存是规整的采用指针碰撞进行分配,如果不规整采用空闲列表进行分配。至于规整还是不规整取决于采用哪种垃圾收集算法。
  5. 分配空间时如果Eden区满了触发minor gc,如果Old区满了触发full gc。
  6. 在触发minor gc前执行老年代空间分配担保机制,看条件是否需要先触发full gc。
  7. 在触发minor gc后执行对象动态年龄判断,根据条件把对应年龄的Survivor 区对象放入到老年代中
  8. 触发minor gc后把Eden的垃圾对象根据垃圾清除算法清理掉,把存活的对象放入到一块Survivor区, 并且分代年龄+1,等待下一次的minor gc。
  9. 在下一次的minor gc时,重复6、7步骤后再把Eden和Survivor存在对象的区域进行垃圾处理,存活下来对象移动到另一块空的Survivor区,并且分代年龄+1等待下一次的minor gc。
  10. 每经历一次 minor gc是Survivor区存活的对象分代年龄都会+1,如果分代年龄到达15(CMS是6,不同垃圾收集器有所不同)或者到达-XX:MaxTenuringThreshold参数值,进入到老年代。
  11. 如果触发了full gc后,老年代没有足够的空间存放对象,就会抛出OOM异常也就是内存溢出。

对象回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。那么如何判断对象时候死亡呢?比较常见的有两种算法:引用计数法可达性分析

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0 的对象就是不可能再被使用的。 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC回收器回收他们。

package common;
public class ReferenceCountingGc {
	Object instance = null;
	public static void main(String[] args) {
		ReferenceCountingGc objA = new ReferenceCountingGc();
		ReferenceCountingGc objB = new ReferenceCountingGc();
		objA.instance = objB;
		objB.instance = objA;
		objA = null;
		objB = null;

	 }
}

可达性分析算法

将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的 对象都是垃圾对象
GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等
在这里插入图片描述
java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用
强引用:普通的变量引用 public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。
public static SoftReference<User> user = new SoftReference<User>(new User());
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用
虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一 个对象死亡,至少要经历再次标记过程。
也就是说,通过可达性分析判断出对象是垃圾对象,如果该对象重写了finalize()的方法,会在回收前调用finalize()方法,如果没有重写finalize()方法则直接回收。如果在finalize()方法中重新有了引用,那么就可以实现自我救赎。如果执行了finalize()仍然没有引用,那么就直接回收。当然并不推荐使用finalize()。了解即可
注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。

类的回收

full gc也会触发方法区的回收。方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
类需要同时满足下面3个条件才能算是 “无用的类” :

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

方法区的类回收条件相当苛刻,full gc后方法区基本没有什么类可以回收的。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值