文章目录
1 CAS
data++ 自增赋值并不是原子的,跟 Java内存模型有关。
在非线程安全的图示中有标注执行线程本地,会有个内存副本,即本地的工作内存,实际执行过程会经过如下几个步骤:
(1)执行线程从本地工作内存读取 data,如果有值直接获取,如果没有值,会从主内存读取,然后将其放到本地工作内存当中。
(2)执行线程在本地工作内存中执行 +1 操作。
(3)将 data 的值写入主内存。
简单讲一共三步:读取 - 修改 - 写回
。多线程环境下肯定会有问题,假如线程A在修改的过程中,线程B已经修改并且写回了一个新的值,此时线程A再写回的时候就会出现覆盖的问题。
CAS过程:
- 首先读取data
- 对data进行修改
- 再次读取data。如果读取结果和步骤1中的一致,则正常写回修改后的结果。否则,从步骤1开始重新执行。
2 synchronized与volatile的硬件级实现
2.1 一个Object在内存中的结构分布?
使用JOL工具。
通过System.out.println(ClassLayout.parseInstance(o).toPrintable())
,将对象内存布局打印出来。以下是一个Object o = new Object();
的内存布局。
具体来说,一个对象的内存结构如下:
- markword: 关于锁的信息,关于对象的所有信息
- 类型指针:指向一个.class对象,表示这个对象属于哪个类
- 实例数据:对象的成员变量。例如一个int m占4个字节,一个long占8个字节。如果是一个引用类型,则占4个字节。
- 对齐:如果对象占用字节数不能够被8整除,则额外补齐一部分使整体字节数能够被8整除。因为数据读取是按照总线宽度来读的。
关于为什么引用类型占4个字节
打印java指令的默认参数可以发现:
- 注意UseCompressedClassPointers指的是使用压缩指针。UseCompressedOops(Oop表示Ordinary Object Pointers)表示使用压缩的对象指针。通常java虚拟机是64位的,则地址也是按照64位(即8个字节)保存。使用压缩指针后,每个指针只占用4个字节。
综上所述,对象头(包含8字节的markword和4字节的类型指针)占12字节,实例数据因为Object里没有成员变量,因此占0字节。最后通过对齐补齐到16字节。
markword里记录了对象的上锁信息。当程序执行synchronized(o){...}
的时候,synchronized上锁的信息保存在了o的markword区域(头8个字节里)。
2.2 锁
-
在jdk1.0-1.2,synchronized就是向操作系统申请的重量级锁。
-
在jdk1.5之后,java对synchronized做了优化。使用synchronized上锁存在一个升级的过程。先是偏向锁,随后轻量级锁,都不能解决问题时转为重量级锁。
-
new对象 -> 偏向锁 -> 轻量级锁(无锁、自旋锁、自适应锁) -> 重量级锁。
在2.1节提到过,锁信息都保存在对象的markword的8个字节里。如下图所示。
2.2.1 锁升级过程
synchronized(o)上锁过程如下:
- 如果当前对象处于new状态,则给对象markword里添加当前线程指针信息,此时就是偏向锁。上偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程
偏向锁不可重偏向 批量偏向 批量撤销。 - 在偏向锁阶段,一旦有一个新线程申请锁,则对象首先
撤销偏向锁
状态。假设此时线程A和B抢占一把锁。A和B线程此时在他们的线程栈中都生成一个LockRecord对象。A和B线程此后去尝试将LockRecord指针
写入到锁对象的markword里,谁写入成功了这个锁就归谁。这个过程通过CAS自旋实现。一旦有线程成功写入自己的LockRecord,则其他线程开始自旋等待。 - 转为重量级锁:
- jdk1.6之前:如果自旋超过10次,或者自旋线程数量超过CPU核数的一半,转为重量级锁。(这个值可以设置)
- jdk1.6之后:自适应自旋,jdk自己控制
升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间。
2.2.2 锁消除
对于线程安全的对象StringBuffer,如果它只在方法中出现(即它是一个局部变量),不可能被其他线程调用(因为局部变量保存在线程的虚拟机栈里,是每个线程独有的),因此该对象一定不可能是共享的,JVM会自动消除StringBuffer内部的锁。
public void add(String str1,String str2){
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
2.2.3 锁粗化
public String test(String str){
int i = 0;
StringBuffer sb = new StringBuffer():
while(i < 100){
sb.append(str);
i++;
}
return sb.toString():
}
JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。
3 synchronized实现过程
- java代码:synchronized
- java字节码(.class文件):monitorenter monitorexit
- 执行过程中自动进行锁升级(偏向锁-自旋锁-重量级锁)
- 汇编语言:
lock cmpxchg
4 超线程
传统上:一个核只有
- 一个ALU
- 一个(组)寄存器【指令寄存器(IR)、程序计数器(PC)、地址寄存器(AR)、数据寄存器(DR)、累加寄存器(AC)、程序状态字寄存器(PSW)】
- 一个PC程序计数器。
当多个线程切换时(context switch),需要首先保存上一个线程的寄存器和pc地址,然后加载下一个线程的寄存器和pc地址。
超线程:一个ALU对应多个PC和寄存器的组合,这样两个线程可以同时运行在一个核上。
5 volatile
5.1 可见性与cacheline缓存行
程序读取数据,按照L1缓存-L2缓存-L3缓存-主存的顺序去读(如果L1中找不到,则去找L2,以此类推)。
实际读取的时候,不应该只读取需要的那一个数据,通常它附近的数据也很有可能要被读,因此通常是按块读取,即读取所需数据所在的块。这里称为cacheline(缓存行)。缓存行通常为64字节。
结合volatile可知:
- volatile保证线程的可见性。当一个线程修改了volatile修饰的对象,会立刻通知到其他使用该对象的线程,你的CPU缓存失效了,必须从主存中取得这个对象的值!
- cacheline机制会导致,一旦cacheline中的某个数据被修改了(该数据以volatile修饰),则其他核中的整个cacheline都会失效,都需要重新从主存中读取!但是假如线程A只使用且修改了cacheline中数据2,线程B只使用且修改了cacheline中的数据3,两个线程之间应该互不干涉才对。所以cacheline机制实际造成性能下降
- 因此引入缓存行对齐的机制。强行padding额外的数据,让上述的数据2和数据3分布在两个cacheline上,这样就不会相互影响了。
总结:CPU级别的数据一致型是以cacheline为单位的。缓存行中如果一个数据标记为volatile,则该数据的任何修改都将造成任何使用其所在的cacheline的线程重新从主存中读取数据
!
测试:
案例1:没有做缓存行对齐
上述程序中,arr[0]和arr[1]会分布在一个cacheline上(因为long占8字节,一个cacheline是64字节。且数组是连续存储的,两个相邻下标的数据保存在连续的内存上)。线程t1不断修改arr[0],线程t2不断修改arr[1]。因为arr[0]和arr[1]在一个cacheline上,任何线程对其中一个值做出修改,都会导致另一个线程在获取值的时候必须从主存里读取,造成很大的耗时。
上述程序最终耗时约200多纳秒。
案例2:做缓存行对齐
(后续部分和案例1一致)
注意这里的T继承自Padding。一个T对象占64字节,刚好填满一个cacheline。因此arr[0]和arr[1]会分布在两个cacheline上,他们之间不会互相影响。
该程序运行花费约80纳秒。
5.2 禁止重排序
5.2.1 重排序的根源
-
对于现代cpu而言,性能瓶颈则是对于内存的访问。cpu的速度往往都比主存的高至少两个数量级。因此cpu都引入了L1_cache与L2_cache,更加高端的cpu还加入了L3_cache.很显然,这个技术引起了下一个问题:
-
如果一个cpu在执行的时候需要访问的内存都不在cache中,cpu必须要通过内存总线到主存中取,那么在数据返回到cpu这段时间内(这段时间大致为cpu执行成百上千条指令的时间,至少两个数据量级)干什么呢? 答案是cpu会继续执行其他的符合条件的指令。比如cpu有一个指令序列 指令1 指令2 指令3 …, 在指令1时需要访问主存,在数据返回前cpu会继续后续的和指令1在逻辑关系上没有依赖的”独立指令”,cpu一般是依赖指令间的内存引用关系来判断的指令间的”独立关系”,具体细节可参见各cpu的文档。这也是导致cpu乱序执行指令的根源之一。
指令重排序的一个形象的例子:
指令重排序演示案例:
public class T04_Disorder {
private static int x = 0, y = 0;
private static int a = 0, b =0;
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);
}
}
}
public static void shortWait(long interval){
long start = System.nanoTime();
long end;
do{
end = System.nanoTime();
}while(start + interval >= end);
}
}
正常情况下,要么x=0, y=1;或者x=1,y=0;或者x=1,y=1。不可能出现x、y都为0的情况。但是因为指令重排序的原因,会有一定概率出现x、y都为0。
5.2.2 饿汉式单例模式必须要加volatile吗?
首先说答案:是必须的。
对于指令T t = new T(int x)
来说,它其实至少分为三步:
- 给对象分配空间,并且给成员变量设置默认初始值(比如int型设置为0)
- 通过构造函数设置初始值
- 返回对象的地址
对于下面的getInstance方法:
private int x;
private static volatile T instance = null;
public static T getInstance(){
if(instance == null){
synchronized(T.class){
if(instance == null){
instance = new T(5);
}
}
}
return instance;
}
private T(int x){
this.x = x;
}
如果instance不加volatile,在instance = new T(5)
这一行,CPU可能指令重排序,首先返回对象的地址。此时instance不为null,但其处于半初始化状态,里面的x还是0。这时如果另一个线程去getInstance,就会拿到这个半初始化的对象。
5.2.3 volatile底层原理
5.2.3.1 内存屏障
以LoadLoad屏障为例,在LoadLoad前后的两个Load指令不能重排序。
5.2.3.2 jvm层面volatile实现细节
不管什么版本的jvm都必须按如下方式实现volatile:
以volatile写操作为例。volatile写操作本身是一个store过程。因此SSBarrier保证volatile写过程和前面的store语句不会重排序。StoreLoadBarrier保证,后面的读必须是前面写入完成之后才能进行。
5.2.3.3 总结:volatile如何解决指令重排序(四个层面)
1:源码层面: volatile i
2:字节码层面: ACC_VOLATILE
3:JVM层面: JVM的内存屏障
4:hotspot实现
bytecodeinterpreter.cpp
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) {
if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
OrderAccess::fence();
}
orderaccess_linux_x86.inline.hpp
inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
6 强软弱虚引用
6.1 强引用
最一般的引用:Object o = new Object();
,只要引用存在,任何情况下堆内存中的对象都不能被回收。一旦堆内存容量达到上限,会报错OOM(Out Of Memory)
6.2 软引用SoftReference
只有在堆内存容量不够的情况下才会回收所指向的对象。
public class SoftReferenceTest {
public static void main(String[] args) {
SoftReference<byte[]> soft = new SoftReference<byte[]>(new byte[1024 * 1024 * 10]); // 10M的数组
System.gc();
System.out.println(soft.get());
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
byte[] bytes = new byte[1024 * 1024 * 15]; //新建一个15M的数组,此时堆内存溢出,必须回收
System.out.println(soft.get());
}
}
这边设置JVM的最大堆内存为23M。这样在新建一个15M的数组后,显然堆内存溢出,gc会自动回收软引用。输出结果如下:
软引用作用:缓存管理
6.3 虚引用
虚引用主要用于管理堆外内存。具体机制不详。NIO上面也用的比较多。
6.4 弱引用
只要垃圾处理器运行,就会回收弱引用指向的对象。
public class WeakReferenceTest {
public static void main(String[] args) {
WeakReference<M> weak = new WeakReference<>(new M());
System.out.println(weak.get());
Runtime.getRuntime().gc();
System.out.println(weak.get());
}
static class M {
int i = 8;
@Override
public String toString() {
return "M{" +
"i=" + i +
'}';
}
}
}
输出结果:
弱引用的作用?一次性的作用。
一个关键的用处:ThreadLocal
6.4.1 ThreadLocal
ThreadLocal本质上就是将变量与线程之间绑定。每个线程只能访问自己关联的那个对象。
public class ThreadLocalTest {
private static ThreadLocal<N> tl = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
tl.set(new N(12));
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(tl.get());
}).start();
}
static class N {
int i = 9;
public N() {
}
public N(int i) {
this.i = i;
}
@Override
public String toString() {
return "N object";
}
}
}
输出结果:null。
其实ThreadLocal解决的就是对于多个线程要操作的同一资源如何进行线程之间隔离的问题。例如spring的事务管理,假如有这么一个方法:
@Service
public class TeamService{
@Transactional(propagation = REQUIRED, rollbackfor = {Exception.class})
public List<Team> getTeams(){
...
m1();
...
m2();
...
}
}
对于m1和m2两个操作,他们可能会访问数据库。由于他们必须是在同一事务下的,因此他们访问数据库时使用的connection必须是同一个。
7 线程的状态
8 多线程下数据如何安全的递增
将一个数据在多线程环境下自增,常见的三种方法:
- synchronized修饰自增过程
- AtomicLong
- LongAdder
LongAdder在线程数非常多的情况下效率比较高。AtomicLong和synchronized相比不一定谁更好(因为synchronized使用了锁升级的机制)。AtomicLong本身采用CAS自旋锁,所以在一些情况下AtomicLong的效率更好。
8.1 LongAdder原理:分段锁
LongAdder会把一个数据放在一个数组里,若干线程分别锁住自己的那一部分,它们分别自增,最后把结果相加。