Java的内存模型,即JMM。依托于操作系统的存储结构,JVM作为虚拟机相当于操作系统之上的一个软件。因此,本文先介绍了操作系统层面的存储结构、硬件级的数据一致性问题以及相对应的一些策略等的知识,这里面也涉及到指令乱序执行的问题,给出了验证的代码。然后结合JVM对于软件层面的一些内存屏障的实现,例如volatile、synchronized关键字。最后是比较重要的java的对象和内存的关系,包括了类的创建过程以及在内存中的排布状态细节,这里包括了通过java代理的使用来观察对象的大小等实际代码的操作。 说明:仍旧是笔记系列。 关键字:JMM,volatile,synchronized,agent,指令重排,乱序证明,对象大小,总线锁,缓存锁,缓存行,伪共享,内存屏障。
1、操作系统的存储结构
呈现一个金字塔型,整体呢又可以把金字塔一分为5层。从上至下依次为:
1、CPU
2、主板
3、内存
4、磁盘
5、网络
可以看到CPU是最快的,但是最窄,也就是说它虽然快,但存储量不大。CPU又可以分为三层,从上至下分别为:
1、寄存器register
2、L1高速缓存
3、L2高速缓存
从1到3,也是尊崇金字塔规律,越来越慢,但是存储量越来越大。这是一颗CPU内部的存储结构。继续看上面的5层结构,CPU下面就是主板。主板上面插着内存条、磁盘、网卡等。按道理来讲他们是平级的,但是由于内部原理不同,他们的存储速度和存储量有很大的差别。CPU下面是主板,多颗CPU共享一块L3高级缓存,注意这个L3高级缓存不是内存,而是主板自带的,它比内存要快,但是没有内存储量大。接下来继续往下,就是内存,我们比较熟悉,然后是磁盘,也叫硬盘,那么其实磁盘之上内存之下还应该有一个SSD固态硬盘的存在,这个我们也比较了解。最底部一层是网络,基于网络存储肯定是最慢的,然而它的储量上不封顶,远不是一台主机能比拟的。
2、硬件层数据一致性问题
2.1 CPU缓存不一致
前面提到了,从主板开始,多颗CPU共享存储空间,也就是说除了寄存器、L1、L2是单颗CPU的内部存储结构以外,金字塔再往下的存储工具,都是被多颗CPU所共享的。所以这就有一个问题,就是在多颗CPU在读取共享存储空间的时候,要保证数据的一致性。那么这个保证该具体如何实现呢?有总线锁和缓存锁。
2.2 总线锁
那么一种实现的情况,是相当于一个CPU在访问共享空间的时候,为数据上锁,其他CPU在访问时要等待。但这样的话,会造成一个CPU在读取共享空间会给整个总线上锁,其他CPU哪怕要访问其他的变量,也必须要等待。所以它很慢,这种实现情况已经过时。
2.3 缓存锁:MESI Cache一致性协议
硬件层的一致性协议很多,intel使用的是MESI。具体实现原理就是,给每一个缓存做了标记,相对于主存,它的四种标记状态有:Modified、Exclusive、Shared、Invalid,这四种状态的首字母合起来就是MESI。具体的协议:
1、Modified,修改过。
2、Exclusive,只有我在用,其他人不关心
3、Shared,大家都在用
4、Invalid,我用的时候,别人改过了。所以这时候,我要再主动去主存中读取一遍。
这四种状态以及特定处理,保证了各个CPU之间的缓存保持一致性。
但有些无法被缓存的数据,或者跨越多个缓存行的数据,依然必须使用总线锁。因此现代CPU的数据一致性实现是通过缓存锁(MESI ...)加上总线锁。
2.4 缓存行
CPU读取缓存以cache line为基本单位,目前多数实现是64字节。
2.5 伪共享
假如两个数据x和y位于同一个缓存行,被CPU所读取,会产生一个问题。当一颗CPU中的x被修改,会通知另一个CPU重新读取(相当于MESI的Invalid状态,那么就需要重新读取一遍)。然后另一个CPU其实没有用到x,它只是用了y,但是由于x和y在同一个缓存行,所以它被迫要重新读。相同的情况可能会蔓延到各个CPU,这会带来无效的开销,是由缓存行带来的。这种问题叫做伪共享。
伪共享指位于同一缓存行的两个不同数据,被两个不同的CPU锁定,产生互相影响的伪共享问题。
2.6 缓存行对齐
就是针对伪共享的情况,还是以上面的x和y为例,就是强制把x和y放到两个缓存行中,这样一来,x和y就不再互相影响,会提高效率。这就是缓存行对齐。Disruptor高性能开源框架就采用了缓存行对齐的能力。那么如何把x和y放到两个缓存行中呢?我们知道一个缓存行是64字节,我们可以在对象申请内存空间的时候,先定义7个long类型变量先占据56个字节,然后只剩8个字节放x,那么这个缓存行肯定是满了,这时候y再申请的时候一定会放到另一个缓存行中。
缓存行对齐其实就是空间换时间,通过浪费掉56个字节的空间,完成对于伪共享问题的避免,实现缓存行对齐,提高性能。
2.7 乱序执行
CPU执行速度非常快,大约是主存IO速度的100倍。假设CPU从内存中读入5条指令,其中第一条指令是从内存中读取一个值。如果CPU在执行第一条指令的时候,什么也不做,它需要等待100倍的空闲时间,这无疑是浪费的。因此,现代CPU都会在执行内存读取时,不去等待而是去分析下一条指令内容,不让CPU空闲着,尽可能提高效率。如果下一条指令与第一条没有直接依赖关系,则可以在第一条指令还在内存取值的时候,CPU直接去执行下一条指令。这时候看上去,第一条指令和第二条指令是并行着的。所以这时候,CPU就是一个乱序执行。
高性能编程得合理利用操作系统底层硬件设计,所以看上去没什么道理。这里使用WCBuffer去合并写,如果你能用代码把这个特性利用上了,那么你的程序肯定比没利用上底层特性的要快!
上面是分析乱序执行读的操作,写操作也可以提高效率,就是通过CPU寄存器和L1缓存中间的WCBuffer来执行。WCBuffer是Write Combining Buffer,即写合并缓存。这个缓存只有4个字节。所以如果要利用WCBuffer,即写合并,就需要控制数据在4个字节的空间里进行操作,多条指令在该缓存中进行合并操作,然后一次性把结果写入低级存储。
2.8 乱序证明
代码语言:javascript复制public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;;) {
i ;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
shortWait(100000);
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
String result = "第" i "次 (" x "," y ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
//System.out.println(result);
}
}
}
这段代码的一些情况:
1、x和y初始化都是0。
2、for循环是无限循环,只有x和y同时等于0的时候才会跳出。
3、判定x和y同时等于0的跳出条件是在两个线程执行以后执行的。
4、第一个线程首先会等待10_0000L毫秒,强制让第二个线程先执行。
5、第一个线程a=1和x=b。第二个线程b=1和y=a。注意,他们之间都不存在指令依赖。
如果线程内指令是顺序执行的,那么有几种结果:
1、第二个线程b=1肯定会先执行成功,而第一个线程a=1不一定在y=a之前执行完毕。若a=1未执行完毕,则(x,y)=(1,0)。
2、若a=1时,y=a还未执行,则(x,y)=(1,1)。
如果第一个线程不设等待时间,则第二个线程的b=1未必会先执行。则结果除了以上两种情况以外,分析的内容差不多就省略了,直接列出结果:
1、若x=b执行时,b=1还未执行完毕,则(x,y)=(0,1)。
2、若x=b执行时,b=1已执行完毕,则(x,y)=(1,1)。
所以,即使第一个线程不增加等待,(x,y)=[(1,0),(0,1),(1,1)],就是没有(0,0)的情况发生!
那么,当CPU指令乱序执行的时候,可能会出现(0,0)的情况,因为这两个线程内分别的2个指令均没有上下的依赖关系,所以乱序执行的前提有了。那么如果乱序执行,什么时候会出现(0,0)的情况呢?其实很简单,就是这两个线程内分别的2个指令的执行顺序全部颠倒了,即:
1、第一个线程,先执行x=b,再执行a=1。
2、第二个线程,先执行y=a,再执行b=1。
那么就可能出现:
1、当x=b执行的时候,b=1尚未执行,x=0。
2、当y=a执行的时候,a=1尚未执行,y=0。
所以,当程序能够识别到(0,0)的情况时,也就反证了CPU乱序执行指令了。下面看一下输出结果:
第461766次 (0,0)
一定会输出结果的,因为前提条件有了(指令间无依赖关系),则CPU乱序执行指令是确定的,只是执行时间的长短就不一定了,你不一定要等待多久,才能等到这个输出。
3、特定情况下保证有序
3.1 硬件级别保证有序
x86 CPU内存屏障:
- sfence,save屏障。在sfence指令前的写操作必须在指令后的写操作前完成。
- Ifence,load屏障。读操作不能重排。
- mfence,读写混合。读写操作均不能重排。
原理就是通过硬件级CPU的设定,在两条有可能乱序执行的指令之间加一道栅栏(fence),强制保证不会乱序执行。
3.2 intel lock汇编指令
intel汇编指令集中也包括了lock命令,也可以实现屏障。
3.3 JVM的内存屏障
JVM的内存屏障依赖于硬件级别的内存屏障或者汇编的lock指令。JVM本质上是操作系统上面的软件,所以JVM来保证内存屏障是作为软件来依赖硬件的实现。JVM的内存屏障的规范包括:
- LoadLoad屏障:两条指令都是读,中间加一层双读内存屏障,保证顺序。
- StoreStore屏障:两条指令都是写,中间加一层双写内存屏障,保证顺序。
- LoadStore屏障:先读后写两条指令,中间加一层读写内存屏障,保证顺序。
- StoreLoad屏障:先写后读两条指令,中间加一层写读内存屏障,保证顺序。
不同的cpu的硬件层面的实现不同,例如龙芯、x86、arm等芯片设计框架,他们对于内存屏障的实现都不同,而JVM是跨平台的,它提出一种规范,基于不同底层向上提供统一的软件层级的内存屏障实现。
4、volatile
一段java代码要经过几个阶段:编码=》Class=》JVM=》操作系统。那我们研究volatile,也按照这个路线循迹追踪。
4.1 字节码
先编写一段代码:
我们看到变量a和b在字节码层面的唯一区别是访问标志,Access Flag不同,加了volatile修饰的变量b会变为0x0040。
4.2 JVM的处理
JVM对于volatile的实现,是基于3.3的处理。
1、针对写操作,StoreStore内存屏障 volatile 写操作 StoreLoad内存屏障。为什么后面是StoreLoad呢?第一个Store好理解,因为前面接的是写操作,第二个Load的含义就是你读的操作必须在我写完以后,保证了一致性。
2、针对读操作,LoadLoad内存屏障 volatile 读操作 LoadStore内存屏障。这里也一样,为什么后面是LoadStore呢?第一个Load是因为前面接的是读操作,第二个Store就是你再接写的指令一定是在我读完以后,保证了一致性。
4.3 操作系统层面
通过汇编代码去调用底层硬件层面的能力,保证对内存区域加锁,lock汇编指令。
工具:hsdis,HotSpot Dis Assembler。JVM反汇编。
5、synchronized
目前synchronized指令的优化速度已经可以替代volatile关键字来执行线程安全了。
我们仍旧按照字节码、JVM以及操作系统的方式来分析synchronized关键字的内部实现原理。
5.1 字节码
首先写一个代码,我们定义一个同步的空方法m,观察它的字节码。
与volatile关键字相同,我们增加的m方法只是增加了访问标志Access_Flag为synchronized。
下面再看一下synchronized关键字上锁的一种使用方法所对应的字节码的情况。
当我们使用synchronized关键字来上锁的时候,可以看到JVM的字节码有了变化。如上图所示,JVM的code,有monitorenter和monitorexit的指令。
注意这个指令是JVM的指令集,不是操作系统的。
所以,这种方式的JVM级别的实现已经与volatile不同了,synchronized关键字明显能够支持的情况更加多元。
有两个exit的意思,是因为其中有一个是处理异常退出的情况。
5.2 JVM的处理
JVM这里的处理是通过C 实现的内容,它会拿到Class的字节码以后去执行,我们可以参照JVM规范查询一下monitorenter和monitorexit的内容。
1、monitorenter,进入一个针对某对象的监控中,它的字节码是0xc2。每个对象都会关联一个监控器,当这个监控器有owner的时候,会被上锁。它可以有1个或多个对应的monitorexit指令,为了实现synchronized状态。这里面包括对于操作数栈的访问计数的控制,当它为0的时候,可以给它指定owner。
2、monitorexit,执行monitorexit指令的当前线程一定是监控器的owner,它关联者一个对象实例。当操作数栈中的访问计数为0的时候,当前线程退出监控器,不再是当前对象的owner。其他线程可以尝试成为这个owner。
其实这个过程就是JVM在内存中对于操作权利的监控,用来调度不同线程的owner身份。
5.3 操作系统层面
仍旧是通过汇编语言的方式,x86的话还是lock comxchg XXXX 语句。
6、指令排序规范
6.1 java并发内存模型
java线程 <> 工作内存 <> |save| 主
java线程 <> 工作内存 <> |and | <==> 内
java线程 <> 工作内存 <> |load| 存
6.2 happens-before原则
JVM规范,规定有些指令不可以指令重排。java语言规范,Java Language Specification,有专门的说明,有哪些情况不能指令重排。
as if serial ,单线程执行结果与多线程指令重排的结果一致。
7、对象与内存
7.1 对象的创建过程
T t = new T();`
1、classloading
2、class linking(verfification, preparation, resolution)
3、class initializing
4、申请对象内存
5、成员变量赋默认值
6、调用构造方法<init>,①成员变量顺序赋初始值。②执行构造方法语句(先调用父类,再调用子类,若子类有实现)。
7.2 对象在内存中的存储布局
7.2.1 观察JVM虚拟机配置
通过java -version中间增加 -XX: PrintCommandLineFlags,进行查看。
-XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=10 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MinHeapSize=6815736 -XX: PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX: SegmentedCodeCache -XX: UseCompressedClassPointers -XX: UseCompressedOops -XX: UseG1GC openjdk version "15.0.5" 2021-10-19 OpenJDK Runtime Environment Zulu15.36 13-CA (build 15.0.5 3-MTS) OpenJDK 64-Bit Server VM Zulu15.36 13-CA (build 15.0.5 3-MTS, mixed mode, sharing)
可以观察到虚拟机的初始堆大小、最大堆大小等信息。
7.2.2 普通对象
1、markword,对象头, 在HotSpot JVM中被称为markword,8个字节。
2、ClassPointer,类指针。一个对象有一个指针指向Class,例如对象t有一个指针,指向T.Class。
3、Instance,实例数据,成员变量,类指针下面有一个区域,记录成员变量的内容,例如int a =8。
4、Padding对齐,8字节的倍数。64位机器是按块读取,如果不足一个块,就是需要对齐。
7.2.3 数组对象
1、对象头
2、CLassPointer
3、数组长度,指定数组长度,4个字节。
4、数组数据
5、对齐
区块链的区块跟这个设计有点像,包括网络传输对象,也都是相似。
7.2.4 实例:查看对象大小
类加载器中很多具体的例如加载的操作都是native方法完成的,native方法是操作系统级别的本地方法,是C 实现的,这部分内容对于java来讲是黑盒的。但是在Class文件被加载到JVM之前,Java提供了一个代理,它在中间拦截,可获得Class的具体信息。本例就是通过Agent功能来获取对象的大小。
首先来编写Agent,最好新起一个单独的工程。创建一个AgentObjSize类。
代码语言:javascript复制package com.evsward.agent;
import java.lang.instrument.Instrumentation;
public class AgentObjSize {
/**
* 通过调试器,可以获得对象大小
*/
private static Instrumentation inst;
public static void premain(String agentArgs, Instrumentation _inst) {
inst = _inst;
}
public static long sizeOf(Object o) {
return inst.getObjectSize(o);
}
}
其中要注意到的是Instrumentation类,这是一个仪表接口,可以理解为JVM虚拟机的仪表。它是在java.lang.instrument包下。
Instrumentation 提供了调试Java代码所需要的服务。调试器是将字节码添加到方法中,以便收集工具所使用的数据。
所以声明静态的Instrumentation对象以及声明静态premain方法是固定的写法。sizeOf方法是我们获取到inst实例以后自己编写使用的,用于对外提供服务。
如果要想该类AgentObjSize真正成为Agent,还需要指定MINIFEST.MF文件。在src下新建包META-INF,然后手动创建MF文件,或者也可以在Idea中配置Project Structure -> Artifacts -> 添加JAR -> copy to the output directory and link via manifest,确定以后,会在项目文件中自动帮你生成好META-INF包和MINIFEST.MF文件,然后再手动修改MINIFEST.MF文件即可。最后回到项目中Build -> Build Artifacts -> build,打包成jar。
代码语言:javascript复制Manifest-Version: 1.0
Main-Class:
Premain-Class: com.evsward.agent.AgentObjSize
以上是MINIFEST.MF最终的内容情况。
然后我们开启一个新工程,把上面的jar引入到工程中(Project Structure -> Libraries -> 添加jar进来)。然后新建一个类ObjectUseAgent用来调用Agent方法。
代码语言:javascript复制package com.evswards.jvm;
import com.evsward.agent.AgentObjSize;
public class ObjectUseAgent {
public static void main(String[] args) {
System.out.println(AgentObjSize.sizeOf(new Object()));
System.out.println(AgentObjSize.sizeOf(new int[]{}));
System.out.println(AgentObjSize.sizeOf(new P()));
}
//一个Object占多少个字节
// -XX: UseCompressedClassPointers -XX: UseCompressedOops
// 开启UseCompressedOops,默认会开启UseCompressedClassPointers
// Oops = ordinary object pointers
private static class P {
//8 _markword
//4 _class pointer
int id; //4
String name; //4
int age; //4
byte b1; //1
byte b2; //1
Object o; //4
byte b3; //1
}
}
然后去执行该main方法,执行前要Edit Configuration,在vm options命令中添加:
-javaagent:{指向你的jar包全路径}.jar
该参数-javaagent就是用来指定agent类的。然后再运行main函数,可以得到结果:
16 24 48
注意,这里默认设定了压缩参数,开启UseCompressedOops,默认会开启UseCompressedClassPointers。
所以,我们可以在上面vm options命令中继续添加 -XX:-UseCompressedClassPointers以及 -XX:-UseCompressedOops命令,然后去执行main方法,查看结果的不同。对象的大小,会受到压缩参数的影响,若加入压缩,ClassPointer指针会被从8个字节压缩成4个字节,另外的引用对象,例如String、Object的对象也会被从8个字节压缩成4个字节。这里可以分别尝试添加与删除压缩参数然后执行一下main方法去验证。
7.2.5 对象头的具体内容
对象头是markword,HotSpot JVM 64位机器,8个字节,定义在markOop.hpp文件中:
包括了:
1、锁定信息,代表了该对象是否被锁定。
2、GC的标记,用作垃圾回收
3、对象的hashcode,System.identityHashCode(...)
8个字节共64位,通过对这64位内存空间的使用,记录了对象的状态。
7.2.6 对象定位
当我们new出一个对象的时候,对象是如何找到类的,有两种方式:
1、句柄池,间接指针。
2、直接指针,HotSpot使用。