1. 锁概念
多个进程之间共享数据,如果多个进程同时对共享数据进行修改就会出现并发问题,那么如何解决并发问题,这里可能需要用到锁,那么锁是如何实现的?
1.1 原子操作
在操作系统中,锁可以看成对内存中一个共享变量的修改,多个进程竞争锁,可以理解为多个线程竞争一个变量的修改。那么如何保证多线程环境下共享变量只能被一个进程修改。以下3种是硬件提供修改数据的原子操作。
- 屏蔽中断:在单核cpu中,多线程会根据时钟中断切换线程,线程a可能会在它将共享变量从内存读到cpu时发生线程切换,这时共享变量并没有被修改,线程b也可以修改共享变量。再次切换到线程a时发现线程a还可以执行线程修改,这样就会有两个线程修改了共享变量,同时获得了锁,这时可以通过屏蔽时钟中断来关闭线程切换。但是这只适应于单核(屏蔽中断只对指定cpu有用),而且关闭线程切换可能会导致程序长时间执行。造成线程饥饿。
- 总线锁:线程a将需要读取的内存发出LOCK前缀,锁住内存总线,使其他cpu不能访问共享变量。
- 缓存锁:总线锁降低了效能,所以出现了缓存锁,每个cpu都会有相应的缓存行,当各个cpu将共享变量复制到寄存器中时,先比较如果与旧值相同则修改值(代表加锁成功),如果与旧值不同说明已经被人修改,则表示加锁失败。为了保证缓存一致性需要使用mesi协议。
- mesi协议:将cpu中的缓存分为四种状态:E独占(此时只有一个cpu缓存该变量),S共享(多个cpu缓存了该变量),M已修改(其中一个cpu修改该变量,该cpu中缓存的状态),I已失效(其中一个cpu修改该变量,通过总线通知其他cpu,其他cpu修改状态为已失效)mesi协议主要意思是:如果cpu读取某个缓存变量则广播到其他cpu,然后其他cpu做出回应并修改自身的状态。
note:操作系统中的原子操作
首先处理器会保证基本的内存操作的原子性,比如从内存读取或者写入一个字节是原子的,但对于(cas指令,tsl指令)读-改-写、或者是其它复杂的内存操作是不能保证其原子性的,又比如跨总线宽度、跨多个缓存行和夸页表的访问,这时候需要处理器提供总线锁和缓存锁(CPU的LOCK前缀)来保证复杂的内存操作原子性。
1.2 MESI引入的问题以及优化
- MESI引入的问题
缓存的一致性消息传递是要时间的,这就使其切换状态时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。 - 优化方法:store buffer与失效队列
-
store buffer:只要让CPU发送通知之后不需要同步等待通知即可,然后就有了Store Bufferes,CPU对某个共享变量修改时,向其他CPU发出Invalid指令后不同步等待其他CPU指令的响应了,而是直接把最新值写入到一个缓冲区也就是Store Bufferes里,然后直接可以去干别的事情了,直到所有的CPU都对Invalid响应后,再把共享变量的值从Store Bufferes里拿出来,写入到自己的缓存里同时同步到主存里面去。如果此时本cpu需要读取该变量需要直接从store buffer中读取。
-
失效队列:store buffer是一个很小的区域,store buffer满了之后当cpu修改变量向其他CPU发送失效消息后,还是需要等待其他cpu的应答,但是此时其他cpu繁忙,这时可以建立一个失效队列。cpu把数据失效消息发送到这个“失效队列里面”,然后就返回认为接收方已经恢复了,所以就可以继续执行下面的流程了,而消息接收方也可以在自己有时间的时候再来处理“失效队列”的消息。
-
失效队列与存储缓存带来的问题:多个变量在进行修改时,其顺序可能是不确定的。即重排序
value = 3; void exeToCPUA(){ value = 10; isFinsh = true; } void exeToCPUB(){ if(isFinsh){ assert value == 10;//value一定等于10?! } }
试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10,即isFinsh的赋值在value赋值之前。这种在可识别的行为中发生的变化称为重排序(reordings)。
注意,这不意味着你的指令的位置被恶意(或者好意)地更改。
它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。
public class Main {
static int a = 0;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (a == 0) {
b++;
}
System.out.println("T1得知a = 1");
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
a = 1;
System.out.println("T2修改a = 1");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
上述代码中"T1得知a = 1"可能不会立刻打印出来,因为t1线程一直在做b++操作,导致线程繁忙而不能同步store buffer中的数据到缓存中(cpu同步store buffer中的数据时会有时延),如果把b++改成Thread.sleep(1)则线程t1就可以退出循环了。
1.3 操作系统的锁
- 互斥与同步:表示线程之间的关系,互斥表示某一个时刻线程不能同时执行,同步表示线程顺序执行,只有线程a执行完成,线程b才能执行,即使线程b先执行也会被阻塞。
- 临界区:多线程环境下会出现并发问题的代码。必须通过锁控制线程的进入。以下根据多线程竞争锁失败是否阻塞分为互斥锁与自旋锁两种。它们都需要使用原子操作修改共享变量获取锁
- 共享变量的类型:
- 互斥量:如果希望同一时刻只有一个线程能够加锁成功,可以使用1表示加锁,0表示没有加锁来表示互斥关系,这种共享变量叫做互斥量。互斥锁就是使用互斥量实现的。
- 条件变量:如果某个条件成立则加锁,另一个条件成立释放锁,这种使用条件表示互斥关系的变量称作条件变量,常与互斥锁结合使用,只是把互斥量变成了条件变量。
- 信号量:互斥量只能表示互斥关系,①不能表示类似资源的问题。例如生产消费问题,线程a向缓冲区生产数据,线程b消费数据,当缓冲区满时线程a阻塞,当缓冲区为空时,线程b阻塞。如下,mutex表示互斥量(保证只有一个线程读写),full与empty分别表示缓冲区为满和空的情况。下图中的down与up操作也可以使用pv操作表示。
void producer(void){ int item; while(TRUE){ /* TRUE是常量1 */ item = producer_item(); /* 产生放在缓冲区中的一些数据 */ down(&empty); /* 将空槽数目-1 */ down(&mutex); /* 进入临界区 */ insert_item(item); /* 将新数据放入缓冲区中 */ up(&mutex); /* 离开临界区 */ up(&full); /* 将满槽数目+1 */ } } void consumer(void){ int item; while(TRUE){ /* 无限循环 */ down(&full); /* 将满槽数目-1 */ down(&mutex); /* 进入临界区 */ item = remove_item(); /* 从缓冲区取出数据项 */ up(&mutex); /* 离开临界区 */ up(&empty); /* 将空槽数目+1 */ consume_item(item); /* 处理数据项 */ } }
②不能表示同步关系,例如需要线程a,b顺序执行,当线程b先执行时会阻塞等待线程a执行完成。如果使用互斥量,则当线程a先执行完时,释放锁并唤醒被阻塞线程,可是此时没有被阻塞的线程,这是一步空操作。接着线程b在执行还是会被阻塞(因为a已经执行完了,没有线程去执行唤醒方法了),如果想解决这个问题可以使用唤醒等待位。如果线程数比较多就需要多个唤醒等待位。
这种情况可以使用信号量,它会累计唤醒次数供以后使用。
- mutex(互斥锁-互斥锁(mutex)的底层原理):如果获取成功则进入临界区,如果获取失败需要阻塞该线程(阻塞线程需要系统调用,从用户态转为内核态),释放锁后唤醒阻塞的线程。互斥锁会造成用户态内核态切换,如果临界区的执行时间过长或者锁竞争比较激烈,则阻塞进程会提升性能。反之应该使用自旋锁。
互斥锁的底层使用的是原子交换的指令,简单来说就是将寄存器中值与锁变量在的内存地址交换,如果寄存器返回0表示加锁成功,如果返回1表示加锁失败,如果两个同时进行,则会进行总线仲裁。其中锁变量0表示锁空闲,1表示锁被使用
- lock与trylock:互斥锁提供了两种获取加锁的方法,但是这两种方法有一些区别,lock加锁失败会阻塞,等待锁释放;trylock加锁失败直接返回错误号(如EBUSY),不阻塞,线程可以去执行其他事情。
- spinlock(自旋锁):获取锁失败不阻塞线程cpu继续执行获取锁操作,但是会浪费cpu资源,如果再次竞争获取锁的时间小于线程阻塞唤醒的时间,则使用自旋锁。
- futex:互斥锁的变量是由内核进行管理的,所以加锁和释放锁都需要从用户态转到内核态,这样即使没有用户竞争锁,也需要一次系统调用。futex是一种用户态和内核态混合的同步机制,同步的进程间通过mmap共享一段内存,futex变量就位于用户态的共享内存中且操作是原子的,当进程尝试加锁和解锁时,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不用再执行系统调用了。当通过访问futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait或者wake up)。
2. synchronized锁分析
synchronized分为代码块加锁与方法加锁
①代码块加锁会生成monitorenter(获取锁)和 monitorexit(释放锁)指令,两个指令之间的便是加锁区域②方法加锁会根据ACC_SYNCHRONIZED标志判断然后再获取monitor锁。详情Synchronized解析——如果你愿意一层一层剥开我的心 (juejin.cn)
旧版本的synchronized使用的是重量级锁,即互斥锁,这种锁的优点是当线程竞争比较大时可以提升性能,但线程竞争比较小或者临界区执行时间较短时会降低性能,所以java后续版本针对synchronized做了优化。
- 锁升级:根据线程竞争情况和数量分为,可偏向锁,轻量级锁,重量级锁。根据竞争激烈程度依次向后升级。这些锁都与对象头的markword有关。
- 锁消除:在编译去除不会竞争资源的同步方法,例如StringBuffer的append是一个同步方法,但是不会竞争资源,所以去除锁标记。
- 锁粗化:将多个出现同步的代码块合并成一个临界区,避免假锁和解锁的内核切换。
可偏向锁:当只有一个线程竞争时,通过原子操作设置markword中的偏向线程id为此线程id,即可获得锁,取消了同步。递归进入时根据线程id判断是否可加锁
轻量级锁:当有不太多线程竞争时,且竞争程度不激烈,则升级为轻量级锁。线程将对象的markword复制到线程栈中,并修改对象markword的数据为指向线程栈帧的指针。如果加锁失败不阻塞,使用自旋锁继续执行。
- 重量级锁:使用互斥锁同步。
- note:java原子类中的compareAndSet()方法底层使用的就是cas指令,原子类中的value使用volatile修饰,并且禁止缓存优化,确保原子操作。属于无锁机制。详细说明可以查找参考中的链接。
参考:
原子操作
cpu缓存一致性与mesi
缓存一致性与优化,内存屏障
进程 mutex与spinlock
futex
可偏向锁,轻量级锁与重量级锁
https://zhuanlan.zhihu.com/p/109971253(同步与锁解析)
synchronized原理
compareAndSet
https://blog.csdn.net/ls5718/article/details/52563959(volatile)
进程通信
Synchronized解析——如果你愿意一层一层剥开我的心 (juejin.cn)
Java 锁机制_寒泉-CSDN博客
CPU缓存一致性协议MESi与内存屏障- 博客园 (cnblogs.com)
CAS(Compare and Swap)无锁算法,使用非阻塞同步算法构造堆栈和链表