0.来看一道美团的面试题
这题直接把人给问懵逼了,你能全部答出来吗?
Object o = new Object();
- 请解释对象的创建过程?
- DCL要不要加volatile问题?
- 对象在内存中的存储布局?
- 什么是指针压缩?
- 对象头具体包含哪些内容?
- 对象怎么定位?
- 对象怎么分配?
- new Object()在内存中占用多少字节?
来大家回答下
1.对象创建过程
T t = new T();
- new T 分配空间,赋默认值
- 调用构造方法,赋初始值
- 把t 引用变量指向 T这个内存空间
2.DCL要不要加volatile
public class SingletonInstance {
//volatile 禁止指令重排序
private static volatile SingletonInstance INSTANCE;
private SingletonInstance(){
//禁止调用构造方法
}
//DCL 双重检查锁
public static SingletonInstance getInstance(){
if(INSTANCE == null){
synchronized (SingletonInstance.class){
if(INSTANCE == null){
INSTANCE = new SingletonInstance();
}
}
}
return INSTANCE;
}
}
加volatile是禁止指令重排序,因为一个对象的创建有可能是乱序的,什么意思呢?
正常的创建过程是按下面顺序执行
- new T 分配空间,赋默认值
- 调用构造方法,赋初始值
- 把t 引用变量指向 T这个内存空间
但是,有可能第3步在第2步之前执行,这就会造成对象的属性还没赋值,对象T就被t这个变量指向了,此时这个对象是一个半初始化对象,在多线程的场景下,有可能会被别的线程拿到,所以需要用volatile来禁止这个指令的乱序执行,必须按照顺序去执行
3.对象在内存中的内存布局
一个对象在内存中的布局分为3个部分分别是:
- 对象头:包括了对象布局、锁、类型、GC状态、同步状态和标识哈希码,class指针的基本信息,这里面有分为markword(占8个字节),和class pointer(占4个字节,原本是8个字节,这里经过指针压缩后变成4个字节)
- 对象数据:就是对象里面属性变量等数据
- 对齐填充:64位虚拟机必须是8的倍数,不能被8整除就需要补齐
来我们通过代码看下
需要用到openjdk 里面的Java Object Layout 对象内存布局工具
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
先准备一个对象T
private static class T{
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
打印出来结果
可以看到header是占用了4+4+4=12个字节
后面一行loss due to the next object alignment是下一次对象补齐缺失的 4个字节,为什么要补齐4个字节呢?
因为前面12个字节/8不能整除,需要补4个自己 12+4=16/8才能整除
所以整个Instance大小是16个字节
看到这里有同学想问,那 instance data怎么没有占用大小呢?因为对象T里面没有属性,所以不会占用大小
那如果在T对象里面加个属性会怎么样?想一想会占用多少字节?
private static class T{
int a;
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
看到没有,还是16个字节为什么呢?
这是因为T对象里面添加了int a这个属性,他是占用4个字节的,此时4+4+4+4=16/8正好能整除,就不需要补齐了,所以整个实例占用大小还是16个字节
那如果再添加一个boolean属性呢,会怎么样?
private static class T{
int a;
boolean flag;
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
此时boolean占用1个自己,那么大小就是hearder12+int a 4+ boolean flag 1 = 17个字节
17不能整除8,那么就需要补17+7=24/8才能整除,所以此时实例对象的占用大小是24个字节
那么如果再添加一个String 对象呢?
private static class T{
int a;
boolean flag;
String str = "hello";
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
有没有发现 boolean下面多了一行 3 (alignment/padding gap) ?
这是因为boolean ,byte short都需要转成int型,所以这个是内部补齐3个字节,因为int 在JVM中是4个字节
但是4 java.lang.String T.str 为什么会是4个字节呢,因为这里的str只是对象的引用,而hello这个字符串是另外一个对象,他是存放在常量区的
这个时候整个实例大小是24正好整除8,不需要补齐
4.指针压缩
刚刚上面那些内存储存空间都是 jvm默认进行压缩了的,来我们来看下jvm参数
重点关注2个参数,一个是类指针,一个是普通对象指针
-XX:+UseCompressedClassPointers:类指针
-XX:+UseCompressedOops:普通对象指针
所以jvm针对类和普通对象都进行了压缩,那么我们把这2个参数关闭,就是不压缩,来看看结果
把+改成-就关闭了
-XX:-UseCompressedClassPointers:类指针
-XX:-UseCompressedOops:普通对象指针
在idea 顶部菜单选中Run–然后选择 edit configurations,加入到jvm配置中
然后再执行看看结果,发现header 的 class pointer变成了8个字节
而String 对象也变成了8个字节,
JVM为什么会进行压缩呢?
这是因为如果应用的对象过多,使用 64位的指针将浪费大量内存,64位的 JVM将会比 32位的 JVM多耗费 50%的内存。为了节约内存对类指针,普通对象指针进行压缩。压缩50%的空间
5.对象头包含哪些信息
上面说到对象头header里面是markword 和class pointer2 部分内容,那具体包含哪些信息呢?
markword 又包括锁的信息,GC的信息 ,还有对象的hashcode
private static class T{
int a;
boolean flag;
String str = "hello";
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
//hashcode
t.hashCode();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
//加锁
synchronized (t){
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
//释放
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
4次打印头里面的数据是不一样的
6.对象怎么定位
什么意思呢?就是t这个引用变量怎么去找到T这个实例
T t = new T();
有2种方式,一个是句柄方式,一个是直接指针方式
7.对象怎么分配内存
1.判断栈上是否有足够空间
这里和之前理解有所差别。之前一直都认为new出来的对象都是分配在堆上的,其实不是,在满足一定的条件,会先分配在栈上。那么为什么要在栈上分配?什么时候分配在栈上?分配在栈上的对象如何进行回收呢?下面来详细分析。
1.1. 为什么要分配在栈上
通过JVM内存模型中,我们知道Java的对象都是分配在堆上的。当堆空间(新生代或者老年代)快满的时候,会触发GC,没有被任何其他对象引用的对象将被回收。如果堆上出现大量这样的垃圾对象,将会频繁的触发GC,影响应用的性能。其实这些对象都是临时产生的对象,如果能够减少这样的对象进入堆的概率,那么就可以成功减少触发GC的次数了。我们可以把这样的对象放在栈上,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
1.2. 什么情况下会分配在栈上
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象会不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存。随栈帧出栈而销毁,减轻GC的压力。
1.3. 什么是逃逸
public class Test {
public User test1() {
User user = new User();
user.setId(1);
user.setName("张三");
return user;
}
public void test2() {
User user = new User();
user.setId(2);
user.setName("李四");
}
}
Test里有两个方法,test1()方法构建了user对象,并且返回了user,返回回去的对象肯定是要被外部使用的。这种情况就是user对象逃逸出了test1()方法。
而test2()方法也是构建了user对象,但是这个对象仅仅是在test2()方法的内部有效,不会在方法外部使用,这种就是user对象没有逃逸。
判断一个对象是否是逃逸对象,就看这个对象能否被外部对象访问到。
结合栈上分配来理解为何没有逃逸出去的对象为什么应该分配在栈上呢?来看下图:
Test2()方法的user对象只会在当前方法内有效,如果放在堆里,在方法结束后,其实这个对象就已经是垃圾的,但却在堆里占用堆内存空间。如果将这个对象放入栈中,随着方法入栈,逻辑处理结束,对象就变成垃圾了,再随着栈帧出栈。这样可以节约堆空间。尤其是这种非逃逸对象很多的时候。可以节省大量的堆空间,降低GC的次数。
1.4. 什么是对象的逃逸分析
就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为参数传递到其他地方中。 上面的例子中,很显然test1()方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
大白话说就是:判断user对象是否会逃逸到方法外,如果不会逃逸到方法外,那么就建议在栈中分配一块内存空间,用来存储临时的变量。是不是不会逃逸到方法外的对象就一定会分配到栈空间呢?不是的,需要满足一定的条件:第一个条件是JVM开启了逃逸分析。可以通过设置参数来开启/关闭逃逸分析。
-XX:+DoEscapeAnalysis开启逃逸分析
-XX:-DoEscapeAnalysis关闭逃逸分析
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)
1.5. 什么是标量替换
如果一个对象通过逃逸分析能过确定他可以在栈上分配,但是我们知道一个线程栈的空间默认也就1M,栈帧空间就更小了。而对象分配需要一块连续的空间,经过计算如果这个对象可以放在栈帧上,但是栈帧的空间不是连续的,对于一个对象来说,这样是不行的,因为对象需要一块连续的空间。那怎么办呢?这时JVM做了一个优化,即便在栈帧中没有一块连续的空间方法下这个对象,他也能够通过其他的方式,让这个对象放到栈帧里面去,这个办法就是标量替换。
什么是标量替换呢?
如果有一个对象,通过逃逸分析确定在栈上分配了,以User为例,为了能够在有限的空间里能够放下User中所有的东西,我们不会在栈上new一个完整的对象了,而是只是将对象中的成员变量放到栈帧里面去。如下图:
栈帧空间中没有一块完整的空间放User对象,为了能够放下,我们采用标量替换的方式,不是将整个User对象放到栈帧中,而是将User中的成员变量拿出来分别放在每一块空闲空间中。这种不是放一个完整的对象,而是将对象打散成一个个的成员变量放到栈帧上,当然会有一个地方标识这个属性是属于那个对象的,这就是标量替换。
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配了。开启标量替换参数是
-XX:+EliminateAllocations
JDK7之后默认开启。
1.6. 标量替换与聚合量
那什么是标量,什么是聚合量呢?
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
2.判断是否是大对象,不是就放到Eden区
断是否是大对象,如果是则直接放入到老年代中。如果不是,则判断是否是TLAB?如果是则在Eden去分配一小块空间给线程,把这个对象放在Eden区。如果不采用TLAB,则直接放到Eden区。
什么是TLAB呢?本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。简单说,TLAB是为了避免多线程争抢内存,在每个线程初始化的时候,就在堆空间中为线程分配一块专属的内存。自己线程的对象就往自己专属的那块内存存放就可以了。这样多个线程之间就不会去哄抢同一块内存了。jdk8默认使用的就是TLAB的方式分配内存。
通过-XX:+UseTLAB参数来设定虚拟机是否启用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
2.1. 对象是如何在Eden区分配的呢?
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] allocation1, allocation2;
allocation1 = new byte[60000*1024];
}
}
来看这段代码,定义了一个字节数组allocation2,给他分配了一块内存空间60M。
来看看程序运行的效果,这里为了方便检测效果,设置一下jvm参数打印GC日志详情
-XX:+PrintGCDetails 打印GC相信信息
2.2. Eden区刚好可以放得下对象
2.3. Eden区满了,会触发GC
3.大对象直接放入到老年代
3.1. 什么是大对象
- Eden园区放不下了肯定是大对象。
- 通过参数设置什么是大对象。-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC。如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
- 长期存活的对象将进入老年代。虚拟机采用分代收集的思想来管理内存,虚拟机给每个对象设置了一个对象年龄(Age)计数器。 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
3.2. 为什么要将大对象直接放入到老年代呢
为了避免为大对象分配内存时的复制操作而降低效率。
3.3. 什么情况要手动设置分代年龄呢
如果我的系统里80%的对象都是有用的对象,那么经过15次GC后会在Survivor中来回翻转,这时候不如就将分代年龄设置为5或者8,这样减少在Survivor中来回翻转的次数,直接放入到老年代,节省了年轻代的空间。