原创 吴就业 149 1 2020-02-22
本文为博主原创文章,未经博主允许不得转载。
本文链接:https://wujiuye.com/article/737eba2c2fc34200938a201bffc35d2f
作者:吴就业
链接:https://wujiuye.com/article/737eba2c2fc34200938a201bffc35d2f
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。
本篇文章写于2020年02月22日,从公众号同步过来(博客搬家),本篇为原创文章。
我们编写一个Java
类,编译后会生成.class
文件,当类加载器将class
文件加载到jvm
时,会生成一个Klass
类型的对象(c++
),称为类描述元数据,存储在方法区中,即jdk1.8
之后的元数据区。当使用new
创建对象时,就是根据类描述元数据Klass
创建的对象oop
,存储在堆中。每个java
对象都有相同的组成部分,称为对象头。
在学习并发编程知识synchronized
时,我们总是难以理解其实现原理,因为偏向锁、轻量级锁、重量级锁都涉及到对象头,所以了解java
对象头是我们深入了解synchronized
的前提条件。
介绍一款可以在代码中计算java
对象的大小以及查看java
对象内存布局的工具包:jol-core
,jol
为java object layout
的缩写,即java对象布局。使用只需要到maven
仓库http://mvnrepository.com
搜索java object layout
,选择想要使用的版本,将依赖添加到项目中即可。
使用jol
计算对象的大小(单位为字节):
ClassLayout.parseInstance(obj).instanceSize()
使用jol
查看对象的内存布局:
ClassLayout.parseInstance(obj).toPrintable()
网络搜索了很多资料,对64
位jvm
的Java
对象头的布局讲解的都很模糊,很多资料都是讲的32
位,而且很多都是从一些书上摘抄下来的,难免会存在错误的地方,所以最好的学习方法就是自己去验证,看jvm
源码。本篇将详细介绍64
位jvm
的Java
对象头。
以User
类为例
public class User {
private String name;
private Integer age;
private boolean sex;
}
通过jol
查看User
对象的内存布局
User user = new User()
System.out.println(ClassLayout.parseInstance(user).toPrintable());
输出内容如下:
object header
为对象头;从图中可以看到,对象头所占用的内存大小为16*8bit=128bit
。如果大家自己动手去打印输出,可能得到的结果是96bit
,这是因为我关闭了指针压缩。jdk8
版本是默认开启指针压缩的,可以通过配置vm
参数关闭指针压缩。
-XX:-UseCompressedOops
现在取消关闭指针压缩的配置,开启指针压缩之后,再看User
对象的内存布局。
开启指针压缩可以减少对象的内存使用。从两次打印的User
对象布局信息来看,关闭指针压缩时,name
字段和age
字段由于是引用类型,因此分别占8
个字节,而开启指针压缩之后,这两个字段只分别占用4
个字节。因此,开启指针压缩,理论上来讲,大约能节省百分之五十的内存。jdk8
及以后版本已经默认开启指针压缩,无需配置。
从两次打印的User
对象的内存布局,还可以看出,bool
类型的age
字段只占用1
个字节,但后面会跟随几个字节的浪费,即内存对齐。开启指针压缩情况下,age
字段的内存对齐需要3
个字节,而关闭指针压缩情况下,则需要7
个字节。
以默认开启指针压缩情况下的User
对象的内存布局来看,对象头占用12个字节
,那么这12
个字节存储的是什么信息,我们不看网上的资料,而是看jdk
的源码。
我当前使用的jdk
版本是jdk1.8
,可通过命令行java -version
查看,也可通过下面方式查看,这个大家应该都很熟悉了。
System.out.println(System.getProperties());
打开官网后点击左侧菜单栏的Groups
找到HotSpot
,在打开的页面的Source code
选择Browsable souce
,之后会跳转到http://hg.openjdk.java.net/,选择`jdk8u`,跳转后的页面中继续选择`jdk8u`下面的`hotspot`。
传送链接:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/。
zip
可将源码打包下载;browse
可在线查看源码;在开启指针压缩的情况下,User
对象的对象头占用12
个字节,本节我们通过源码了解对象头都存储了哪些信息。
在Java
程序运行的过程中,每创建一个新的对象,JVM
就会相应地创建一个对应类型的oop
对象,存储在堆中。如new User()
,则会创建一个instanceOopDesc
,基类为oopDesc
。
[instanceOop.hpp文件:hotspot/src/share/vm/oops/instanceOop.hpp]
class instanceOopDesc : public oopDesc {
}
instanceOopDesc
只提供了几个静态方法,如获取对象头大小。因此重点看其父类oopDesc
。
[oop.hpp文件:hotspot/src/share/vm/oops/oop.hpp]
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
........
}
我们只关心对象头,普通对象(如User
对象,本篇不讲数组类型)的对象头由一个markOop
和一个联合体组成,markOop
就是MarkWord
。这个联合体是指向类的元数据指针,未开启指针压缩时使用_klass
,开启指针压缩时使用_compressed_klass
。
markOop
与narrowKlass
的类型定义在/hotspot/src/share/vm/oops/oopsHierarchy.hpp
头文件中:
[oopsHierarchy.hpp头文件:/hotspot/src/share/vm/oops/oopsHierarchy.hpp]
typedef juint narrowKlass;
typedef class markOopDesc* markOop;
因此,narrowKlass
是一个juint
,junit
是在globalDefinitions_visCPP.hpp
头文件中定义的,这是一个无符号整数,即4
个字节。所以开启指针压缩之后,指向Klass
对象的指针大小为4
字节。
[/hotspot/src/share/vm/utilties/globalDefinitions_visCPP.hpp]
typedef unsigned int juint;
而markOop
则是markOopDesc
类型指针,markOopDesc
就是MarkWord
。不知道你们有没有感觉到奇怪,在64
位jvm
中,markOopDesc
指针是8
字节,即64bit
,确实刚好是MarkWord
的大小,但是指针指向的不是一个对象吗?我们先看markOopDesc
类。
[markOop.hpp文件:hotspot/src/share/vm/oops/markOop.hpp]
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
class markOopDesc: public oopDesc {
......
}
markOop.hpp
头文件中给出了64bit
的MarkWord
存储的信息说明。markOopDesc
类也继承oopDesc
。如果单纯的看markOopDesc
类的源码,根本找不出来,markOopDesc
是用那个字段存储MarkWord
的。而且,根据从各种来源的资料中,我们所知道的是,对象头的前8
个字节存储的就是是否偏向锁、轻量级锁等等信息(全文都是以64
位为例),所以不应该是个指针啊。
为了解答这个疑惑,我是先从markOopDesc
类的源码中,找一个方法,比如,获取gc
对象年龄的方法,看下jvm
是从哪里获取的数据。
class markOopDesc: public oopDesc {
public:
// 获取对象年龄
uint age() const {
return mask_bits(value() >> age_shift, age_mask);
}
// 更新对象年龄
markOop set_age(uint v) const {
return markOop((value() & ~age_mask_in_place) | (((uintptr_t)v & age_mask) << age_shift));
}
// 自增对象年龄
markOop incr_age() const {
return age() == max_age ? markOop(this) : set_age(age() + 1);
}
}
那么,value()
这个方法返回的就是64bit
的MarkWord
了。
class markOopDesc: public oopDesc {
private:
// Conversion
uintptr_t value() const { return (uintptr_t) this; }
}
value
方法返回的是一个指针,就是this
。从set_age
和incr_age
方法中也可以看出,只要修改MarkWord
,就会返回一个新的markOop
(markOopDesc*
)。难怪会将markOopDesc*
定义为markOop
,就是将markOopDesc*
当成一个8
字节的整数来使用。想要理解这个,我们需要先补充点c++
知识,因此我写了个demo
。
自定义一个类叫oopDesc
,并且除构造函数和析构函数之外,只提供一个Show
方法。
[.hpp文件]
#ifndef oopDesc_hpp
#define oopDesc_hpp
#include <stdio.h>
#include <iostream>
using namespace std;
// 将oopDesc* 定义为 oop
typedef class oopDesc* oop;
class oopDesc{
public:
void Show();
};
#endif /* oopDesc_hpp */
[.cpp文件]
#include "oopDesc.hpp"
void oopDesc::Show(){
cout << "oopDesc by wujiuye" <<endl;
}
使用oop(指针)
创建一个oopDesc*
,并调用show
方法。
#include <iostream>
#include "oopDesc.hpp"
using namespace std;
int main(int argc, const char * argv[]) {
oopDesc* o = oop(0x200);
cout << o << endl;
o->Show();
return 0;
}
测试输出
0x200
oopDesc by wujiuye
Program ended with exit code: 0
因此,通过类名(value)
可以创建一个野指针对象,将指针赋值为value
,这样就可以使用this
作为MarkWord
了。如果在oopDesc
中添加一个字段,并提供一个方法访问,程序运行就会报错,因此,这样创建的对象只能调用方法,不能访问字段。
通过倒数三位判断当前MarkWord
的状态,就可以判断出其余位存储的是什么。
[markOop.hpp文件]
enum { locked_value = 0, // 0 00 轻量级锁
unlocked_value = 1,// 0 01 无锁
monitor_value = 2,// 0 10 重量级锁
marked_value = 3,// 0 11 gc标志
biased_lock_pattern = 5 // 1 01 偏向锁
};
现在,我们再看下User
对象打印的内存布局。
通过前面的讲解,我们已经知道,对象头的前64
位是MarkWord
,后32
位是类的元数据指针(开启指针压缩)。
从图中可以看出,在无锁状态下,该User
对象的hashcode
为0x7a46a697
。由于MarkWord
其实是一个指针,在64
位jvm
下占8
字节。因此MarkWordk
是0x0000007a46a69701
,跟你从图中看到的正好相反。这里涉及到一个知识点“大端存储与小端存储”。
学过汇编语言的朋友,这个知识点应该都还记得。本篇不详细介绍,不是很明白的朋友可以网上找下资料看。
接着,我们再看一下,使用synchronized
加锁情况下的User
对象的内存信息,通过对象头分析锁状态。
案例1
public class MarkwordMain {
private static final String SPLITE_STR = "===========================================";
private static User USER = new User();
private static void printf() {
System.out.println(SPLITE_STR);
System.out.println(ClassLayout.parseInstance(USER).toPrintable());
System.out.println(SPLITE_STR);
}
private static Runnable RUNNABLE = () -> {
while (!Thread.interrupted()) {
synchronized (USER) {
printf();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
new Thread(RUNNABLE).start();
Thread.sleep(1000);
}
Thread.sleep(Integer.MAX_VALUE);
}
}
从该对象头中分析加锁信息,MarkWordk
为0x0000700009b96910
,二进制为0xb00000000 00000000 01110000 00000000 00001001 10111001 01101001 00010000
。
倒数第三位为"0"
,说明不是偏向锁状态,倒数两位为"00"
,因此,是轻量级锁状态,那么前面62
位就是指向栈中锁记录的指针。
案例2
public class MarkwordMain {
private static final String SPLITE_STR = "===========================================";
private static User USER = new User();
private static void printf() {
System.out.println(SPLITE_STR);
System.out.println(ClassLayout.parseInstance(USER).toPrintable());
System.out.println(SPLITE_STR);
}
private static Runnable RUNNABLE = () -> {
while (!Thread.interrupted()) {
synchronized (USER) {
printf();
}
}
};
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
new Thread(RUNNABLE).start();
}
Thread.sleep(Integer.MAX_VALUE);
}
}
从该对象头中分析加锁信息,MarkWordk
为0x0000 7ff0 c800 53ea
,二进制为0xb00000000 00000000 01111111 11110000 11001000 00000000 01010011 11101010
。
倒数第三位为"0"
,说明不是偏向锁状态,倒数两位为"10"
,因此,是重量级锁状态,那么前面62
位就是指向互斥量的指针。
声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。
Unsafe、CAS、AQS是我们了解Java中除synchronized之外的锁必须要掌握的重要知识点。CAS是一个比较和替换的原子操作,AQS的实现强依赖CAS,而在Java中,CAS操作需通过使用Unsafe提供的方法实现。
灰度发布是实现新旧版本平滑过渡的一种发布方式,即让一部分服务更新到新版本,如果这部分服务没有什么问题,再将其它旧版本的服务更新。而实现简单的灰度发布我们可以使用版本号控制,每次发布都更新版本号,新更新的服务就不会调用旧的服务提供者。
我看很多资料在介绍`GC Root`时,并没有说栈帧的操作数栈上引用的对象也是`GC Root`,包括我去翻阅《深入理解Java虚拟机》这本书也是一样。所以我才好奇。
订阅
订阅新文章发布通知吧,不错过精彩内容!
输入邮箱,提交后我们会给您发送一封邮件,您需点击邮件中的链接完成订阅设置。