操作系统原子性与锁,synchronized小结

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做出回应并修改自身的状态。

mesi协议的过程

mesi协议中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是一个很小的区域,store buffer满了之后当cpu修改变量向其他CPU发送失效消息后,还是需要等待其他cpu的应答,但是此时其他cpu繁忙,这时可以建立一个失效队列。cpu把数据失效消息发送到这个“失效队列里面”,然后就返回认为接收方已经恢复了,所以就可以继续执行下面的流程了,而消息接收方也可以在自己有时间的时候再来处理“失效队列”的消息。


      vr.jpg

失效队列与存储缓存带来的问题:多个变量在进行修改时,其顺序可能是不确定的。即重排序

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复制到线程栈中,并修改对象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)无锁算法,使用非阻塞同步算法构造堆栈和链表

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容