认识Java对象
先了解一下对象的组成
对象的组成
可以看到,一个对象由:对象头,实例数据,对齐填充组成。
1、对象头
markword
涉及到synchronized底层实现原理,你可以暂时简单地理解为一些关键的额外信息,可以参考:synchronized的轻量级锁居然不会自旋?深度解析synchronized实现原理
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 31bit的Hashcode | 0 | 01 |
偏向锁 | 线程ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC标记 | 此时这一位不用于标识偏向锁 | 11 |
Class指针
4字节的指针(指向类元信息地址,class),用于访问Class对象。
默认开启指针压缩,所以是4字节,关闭的话为8字节。
数组的length
这个好理解,数组的.length
就是拿到这个信息
这里length四个字节,也解释了一个数组的最大长度,是max_int。
2、实例数据
就是类自己的类似private String name = "123"
这样的信息。
像父类的private的属性,也是有的,但访问不了。
3、对齐填充
对齐填充使得对象实例的字节数是8的倍数。
64位JVM的寻址空间更大,但是会带来性能的损耗;大多数计算机都是高效的64位处理器,顾名思义,一次能处理64位的指令,即8个字节的数据,HotSpot VM的自动内存管理系统也就遵循了这个要求,这样子性能更高,处理更快。
因此,new一个对象,实际就是要处理上述三大部分。一般有:
- markword
- Class对象
- 实例数据
new对象的过程
一、检查是否类加载
检查常量池中是否能定位到这个类的「符号引用」。
如果没有Class对象,会先执行类加载过程的1~5步骤。类加载的过程可以参考:JVM架构之类加载系统,深入理解类加载器
二、为实例分配内存
首先因为堆全局唯一,因此需要保证线程安全:
方案是:CAS+TLAB
TLAB:Thread Local Allocation Buffer。就是在Eden区直接划分一块给某个线程私有。后续的new对象都在这块私有区域分配。
划分区域也会线程不安全,也需要CAS或锁,但是一种锁粗化的原理,比单单分配一个对象效率要高得多。
在TLAB不够时,采用CAS的方式分配内存。
CAS:compare and swap,保证线程安全
然后开始具体地分配内存
对象所需的内存大小在类加载完成后便可确定,具体如何分配有两种方式:
没有内存碎片:指针碰撞
用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置
有内存碎片:空闲列表
虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
有没有内存碎片取决于GC算法,清除有内存碎片,复制,整理没有内存碎片
三、初始化实例变量
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值(一般是0)。
四、设置对象头
即初始化mark word,class指针等信息
对象头是下图绿色的部分
五、初始化
先初始化父类再初始化子类。
初始化时先执行实例代码块 然后是构造方法。
这样一个真正可用的对象才算完全产生出来
开发视角:代码块/构造方法/声明调用顺序
我们现在已经学了Class对象的加载,实例对象的加载,这里来梳理一下,父子类的静态(非静态)代码块,构造方法,静态(非静态)字段的调用顺序
Class加载完全早于实例对象的加载,因此:
- 父类的:静态变量声明,静态代码块
- 子类的:静态变量声明,静态代码块
这里的1~2是类加载的(5)初始化阶段
- 父类的:实例变量声明,普通语句块
- 父类的:构造函数
- 子类的:实例变量声明,普通语句块
- 子类的:构造函数
这里的3~6是new对象实例的(5)初始化阶段
没有被声明,代码块,构造函数指定的变量就是默认值(一般为零值)
- 对于静态的字段由类加载步骤(3)准备阶段保证
- 对于普通的字段由new对象步骤(3)初始化实例变量阶段保证
声明语句和代码块哪个先执行
为什么把声明和代码块放在同一行内?它们之间的顺序如何?
实际上,这取决于代码的位置。
我们知道在初始化之前,无论是静态字段,还是普通字段,都会为它们分配空间并赋予默认值,一般为零值。
因此:
arduino
public static int a = 1;
像这样的语句,「为a分配空间」 与 「声明a的值」 ,并不会被一起执行。
完全可以这样写:
ini
static { a = 2; }
public static int a = 1;
此时访问a为1
交换这两行代码的位置:
ini
public static int a = 1;
static { a = 2; }
此时访问a为2
普通字段完全一样,多个代码块也是这样
因此,变量声明与代码块的执行顺序取决于代码的位置顺序
对象如何被访问
句柄
在堆中划分一块区域作为句柄池,指针指向句柄,句柄指向堆内存空间
直接指针
直接指针就是指向堆内存空间的指针
对比
直接指针显然访问速度更快。句柄的好处是对象被移动时(比如GC),只改变句柄中实例数据指针,reference
本身不用改变。
HotSpot VM采用直接指针
引用类型
直接指针提供的功能太少,因此JDK1.2之后Java提供了四个级别的引用,以下按引用强度排序:
强引用(StrongReference)
最普遍的引用,垃圾回收器绝不会回收它,宁愿抛出 OutOfMemoryError 错误,使程序异常终止。
new出来的对象的引用都是强引用。像下面这行代码,obj就是个强引用,存储在栈帧的局部变量表中。
ini
Object obj = new Object();
执行 obj=null;
后,这个obj原本指向的Object实例才可能被GC
软引用(SoftReference)
如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。可有可无。可用来实现内存敏感的高速缓存。
弱引用(WeakReference)
值得被回收。一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
ThreadLocal的key是弱引用
虚引用(PhantomReference)
虚引用:主要用来跟踪对象被垃圾回收的活动。虚引用不会决定GC机制对一个对象的回收权。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
查看Java对象的内存布局
JDK自带
java
System.setProperty("java.vm.name","Java HotSpot(TM) ");
System.out.println(ObjectSizeCalculator.getObjectSize(3L));
jol工具
java
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
java
// 基本使用
Object o = new Object();
// 实例信息
System.out.println(ClassLayout.parseInstance(o).toPrintable());
// class类信息
System.out.println(ClassLayout.parseClass(String.class).toPrintable());
Instrumentation
获取Instrumentation对象,先引入依赖项
xml
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.12.10</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.12.10</version>
</dependency>
java
import net.bytebuddy.agent.ByteBuddyAgent;
import java.lang.instrument.Instrumentation;
ByteBuddyAgent.install();
final Instrumentation instrumentation = ByteBuddyAgent.getInstrumentation();
inst.getObjectSize(obj);
最后来看看Object类,它是Java所有类的父类。
浅析Object类
Object有哪些方法
hashcode,equals,toString,wait,notify,clone
为什么wait/notify在Object内而不是Thread
因为等待/通知机制设计是为了解决线程间通信的。
线程通信是否可执行,是由资源决定的,而非线程。
一个线程使用完毕某个资源,别的线程才能使用。
线程知道自己是因为要获取哪个资源而被阻塞,关注的是资源,而不在乎是因为谁(具体哪个线程)。
输出一个没有重写toString方法的对象会发生什么
输出Person类得到 Person@3c1
输出数组得到 [I@4554617c
输出new Object的对象得到 java.lang.Object@4554617c
一个类没有重写toString方法,也会有这个方法
因为Object类下已经做了实现
typescript
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
也就是会输出 类名 + @ + hashcode的值
(hashcode16进制)
new 一个Object多少字节
这跟硬件是有关系的。还有是否开启指针压缩等。所以没有确切的答案。但可以肯定的是:一定会是8的倍数。开启指针压缩的情况下,为16字节。8字节mark word,4字节指针,以及对齐填充。另外要区分堆与栈。在栈上也会生成一个4字节的指针。
小结一下:new 一个Object,在本地栈生成一个4字节的指针,指向一块堆内存区域。这块堆内存区域存储了markword,实例数据,和一个指针指向方法区class对象(或者用句柄实现,但sunhotspot是指针实现),以及对齐填充