Java高并发编程实战3,Java内存模型与Java对象结构
一、缓存一致性
CPU的缓存一致性要求CPU内部各级缓存之间的数据是一致的。当多个CPU核心涉及对同一块主内存的数据进行读写和计算操作时,可能导致各个CPU核心之间缓存的数据不一致。
通过缓存一致性协议解决缓存一致性问题,比如MSI协议、MESI协议等。
二、伪共享
CPU在读取数据时,是以一个缓存行来读取的。目前,主流的CPU的缓存行大小为64Bytes。所以,一个缓存行中可能存储多个数据(实际存储的是数据的内存块),当多个线程同时修改一个缓存行里的多个变量时,由于MESI协议是针对缓存行修改状态的,就会导致多个线程的性能相互影响,这就是伪共享。
假设缓存行中存储的是64位,也就是8byte的double类型的数据,则一个缓存行可以存储8个double类型的数据。
如果多个线程共享存储在同一个缓存行的不同double数据,并且线程1对变量X的值进行了修改,那么此时,即使线程2并没有修改变量Y的值,即使线程1和线程2不共享同一个变量,线程1和线程2会影响彼此的性能,导致伪共享的问题。
如何解决伪共享问题?
- JDK8之前,通过字节填充的方式解决伪共享的问题;
- JDK8之后,引入@Contended注解来自动填充缓存行,避免伪共享问题;
三、volatile
volatile有两个作用:
1、保证可见性
一个线程修改此变量后,该值会立刻刷新到主内存,其它线程每次都会从主内存中读取更新后的新值,这就保证了可见性;
简而言之,线程对volatile修饰的变量进行读写操作,都会经过主内存。
2、禁止指令重排,通过内存屏障实现的。
JVM编译器可以通过在程序编译生成的指令序列中插入内存屏障来禁止在内存屏障前后的指令发生重排。
volatile虽然可以保证数据的可见性和有序性,但不能保证数据的原子性。
- 读屏障插入在读指令前面,能够让CPU缓存中的数据失效,直接从主内存中读取数据;
- 写屏障插入在写指令后面,能够让写入CPU缓存的最新数据立刻刷新到主内存;
3、重排序
为了提高程序的执行性能,编译器和CPU会对程序的指令进行重排序,可以分为编译器重排序和CPU重排序,CPU重排序又可以分为指令级重排序和内存系统重排序。
程序源码通过编译器重排序、CPU重排序中的指令级重排序和内存系统重排序之后,才能生成最终的指令执行序列。可以在这个过程中插入内存屏障来禁止指令重排。
编译器重排序是在代码编译阶段为了提高程序的执行效率,但不改变程序的执行结果而进行的重排序。
比如,在编译过程中,如果编译器需要长时间等待某个操作,而这个操作和它后面的代码没有任何数据上的依赖关系,则编译器可以选择先编译这个操作后面的代码,再回来处理这个操作,这样可以提升编译的速度。
现代CPU基本上都支持流水线操作,在多核CPU中,为了提高CPU的执行效率,流水线都是并行的。同时,在不影响程序语义的前提下,CPU中的处理顺序可以和代码的顺序不一致,只要满足as-if-serial原则即可。
- 指令级重排序指在不影响程序执行的最终结果的前提下,CPU核心对不存在数据依赖性的指令进行的重排序操作;
- 内存系统重排序指在不影响程序执行的最终结果的前提下,CPU对存放在高速缓存中的数据进行的重排序,内存系统重排序虽然可能提升程序的执行效率,但是可能导致数据不一致。
4、as-if-serial原则
编译器和CPU对程序代码的重排序必须遵循as-if-serial原则,as-if-serial原则规定编译器和CPU无论对程序代码如何重排序,都必须保证程序在单线程环境下运行的正确性。
在符合as-if-serial原则的基础上,编译器和CPU只可能对不存在数据依赖关系的操作进行重排序。如果指令之间存在数据依赖关系,则编译器和CPU不会对这些指令进行重排序。
as-if-serial原则能够保证在单线程环境下程序执行结果的正确性,不能保证在多线程环境下好吃呢个选结果的正确性。
四、Java内存模型
Java内存模型简称JMM,是Java中为了解决可见性和有序性问题制定的一种编程规范。
Java内存模型规定所有变量都存储在主内存中,也就是存储在计算机的物理内存中,每个线程都有自己的工作内存,用于存储线程私有的数据,线程对变量的所有操作都需要在工作内存中完成。一个线程不能直接访问其它线程工作内存中的数据,只能通过主内存进行数据交互。
- 变量都存储在主内存中;
- 当线程需要操作变量时,需要先将主内存中的变量复制到对应的工作内存中;
- 线程直接读写工作内存中的变量;
- 一个线程不能访问其它线程工作内存中的数据,只能通过主内存间接访问;
五、Happens-Before原则
在JMM中,定义了Happens-Before原则,用于保证程序在执行过程中的可见性和有序性。Happens-Before原则主要包括:
程序次序原则表示在单个线程中,程序按照代码的顺序执行,前面的代码操作必然发生于后面的代码操作之前。
volatile变量原则表示对一个volatile变量的写操作,必然发生于后续对这个变量的读操作之前。
传递原则表示如果操作A先于操作B,操作B先于操作C,那么操作A一定先于操作C。
锁定原则表示对一个锁的解锁操作必然发生于后续对这个锁的加锁操作之前。
线程启动原则表示如果线程1调用线程2的start()方法启动线程2,则start()操作必然发生于线程2的任意操作之前。
线程终结原则表示如果线程1等待线程2完成操作,那么当线程2完成后,线程1能够访问到线程2修改后的共享变量的值。
写一段代码,理解一下线程终结原则。
package com.nezha.thread;
public class Test0910 {
private String name = "";
private void threadEnd() throws InterruptedException {
Thread thread = new Thread(()->{
name = "哪吒编程";
});
thread.start();//线程开始
thread.join();//等待线程执行完毕
System.out.println(name);//控制台输出哪吒编程
}
public static void main(String[] args) throws InterruptedException {
Test0910 test0910 = new Test0910();
test0910.threadEnd();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
线程中断原则表示对线程interrupt()方法的调用必然发生于被中断线程的代码检测到中断事件发生前。
对象终结原则表示一个对象的初始化必然发生于它的finalize()方法开始前。
六、Java对象结构
Java中对象结构主要包括对象头、实例数据、对其填充三部分。
1、对象头
对象头中存储了对象的hash码、对象所属的分代年龄、对象锁、锁状态、偏向锁的ID、获得偏向锁的时间戳等,如果当前对象是数组对象,则对象头中还会存储数组的长度信息。
Java中的对象头进一步分为Mark Word、类型指针和数组长度三部分。
Mark Word主要用来存储对象自身的运行时数据,例如,对象的Hash码、GC的分代年龄、锁的状态标志、对象的线程锁状态信息、偏向线程ID、获得的偏向锁的时间戳等。
64位的JVM中Mark Word的结构
- 锁标志位:占用2位存储,锁标志位的值不同,所代表的整个Mark Word的含义不同;
- 是否偏向锁标记:占用1位存储空间,标记对象是否开启了偏向锁。
- 分代年龄:占用4位存储空间,表示Java对象的分代年龄;
- 对象HashCode:占用31位存储空间,主要存储对象的HashCode值;
- 线程ID:占用54位存储空间,表示持有偏向锁的线程ID;
- 时间戳:占用2位存储空间,表示偏向锁的时间戳;
- 指向栈中锁记录的指针:占用62位存储空间,表示在轻量级锁的状态下,指向栈中锁记录的指针;
- 指向重量级锁的指针:占用62位存储空间,表示在重量级锁的状态下,指向对象监视器的指针;
2、实例数据
实例数据主要存储的是对象的成员变量信息。
3、对其填充
在HotSpot JVM中,对象的起始地址必须是8的整数倍。由于对象头占用的存储空间已经是8的整数倍,所以如果当前对象的实例变量占用的存储空间不是8的整数倍,则需要使用填充数据来保证8字节的对齐。
Java高并发编程实战系列文章
Java高并发编程实战2,原子性、可见性、有序性,傻傻分不清
哪吒精品系列文章
文章来源: blog.csdn.net,作者:哪 吒,版权归原作者所有,如需转载,请联系作者。
原文链接:blog.csdn.net/guorui_java/article/details/126798010
- 点赞
- 收藏
- 关注作者
评论(0)