并发编程总结(一):同步和锁的本质

1.线程通信

我们知道,Java中的并发问题原因可以归类于三种:原子性、可见性、有序性。

原子性是由于CPU指令的执行存在中断或线程切换、可见性是由于多核时代每个CPU都有自己的一块高速缓存(寄存器),有序性是由于编译器或CPU针对指令的重排序

而在计算的领域,通常解决上述问题的解决方案是:互斥

由互斥不得不又牵扯出临界区的相关概念,临界区:同时只能由一个线程访问的代码片段

而操作系统层面如何实现互斥的呢?多个线程之间如何去沟通的呢?这就涉及到了通信的概念,那么计算机是如何实现线程通信的呢?

如下两种方案都是采取的互斥量,来保证临界区的代码只在一个CPU上执行,区别只是两者未拿到锁而执行的等待策略不同,一个是采取自旋忙等待,一个是等待-通知

1.1.忙等待的互斥(自旋等待)

忙等待可以理解为进行一直循环判断是否可以取到锁,这种循环也可以称之为自旋

1.1.1.屏蔽中断

屏蔽中断就是禁止CPU的中断操作,从而达到原子性的目的,但是只能针对单CPU生效,多CPU的场景下还是会出现并发的问题

具体实现如下:

如下图,在进程进入临界区之前,调用local_irq_disable宏来屏蔽中断,在进程离开临界区之后,调用local_irq_disable宏来使能中断

image-20200223095140312

1.1.2.基于硬件指令

一般指的是基于冲突检测的乐观机制,先进行操作,如果没有其他进程争用共享数据,就操作成功,否则就循环重试

乐观并发策略一般都需要硬件指令集的发展才能进行,需要硬件指令实现:操作+冲突检查的原子性

这类指令有:

  • 测试并设置锁 Test and Set Lock (TSL)
  • 获取并增加 Fetch-and-Increment
  • 交换 Swap
  • 比较并交换 Compare-and-Swap (CAS)
  • 加载链接/条件存储 Load-linked / Store-Conditional LL/SC

1.1.2.1.TSL(互斥量)

测试并设置锁 Test and Set Lock (TSL),指令格式如下:

TSL RX, LOCK

其实就是多个线程去尝试设置一个标志位的值为1,然后再检查当前值是否为1,如果不是就反复重试

实现的原理:执行TSL指令的CPU会锁住内存总线,禁止其他CPU在这个指令结束之前访问内存。这样就能保证只有一个CPU能够去test and set了

下面是实现的关键代码:

enter_region:
    TSL REGISTER,LOCK      | 复制锁到寄存器并将锁设为1
    CMP REGISTER,#0        | 锁是0吗?,这里的锁是从内存中读到的值,而不是说当前线程已修改的值
    JNE enter_region       | 若不是0,说明锁已被设置,所以循环
    RET                    | 返回调用者,进入临界区

leave_region:
    MOVE LOCK,#0           | 在锁中存入 0
    RET                    | 返回调用者

image-20200223133446606

1.1.2.2.CAS指令

IA64 和 X86 使用lock cmpxchg指令结合(汇编指令)完成CAS功能,CAS长用于对互斥量的修改

cas 内存位置 旧预期值 新值

JDK中的CAS,相关类:Unsafe里面的compareAndSwapInt()以及compareAndSwapLong()等几个方法包装提供。只有启动类加载器加载的class才能访问他,或者通过反射获取,上述方法在jdk中都是native修饰

TSL和CAS的指令本质是,同一个时刻只有一个CPU对信号量(互斥量)进行操作成功

关于CAS的lock cmpxchg指令:

首先,x86汇编当中,如果对一个指令加“lock”前缀,会发生什么:

image-20210902154917389

文档中说了,对于Lock指令区分两种实现方法,

  1. 对于早期的CPU,总是采取锁总线的方式,具体是一旦遇到了Lock指令,就由仲裁器选择一个核心独占,其他核心就不能再通过总线与内存通信,从而达到原子性的目的,但是这样会让其他CPU都无法干活了,

  2. Intel P6 CPU开始就做了一个优化,改用Ringbus+MESI协议,也就是文档里说的cache conherence机制。这种技术被Intel称为“Cache Locking”(缓存行)

    如果对于同一份内存数据在多个核里都有cache,则状态都为S(shared)。一旦有一核心改了这个数据(状态变成了M),其他核心就能瞬间通过ringbus感知到这个修改,从而把自己的cache状态变成I(Invalid),并且从标记为M的cache中读过来。同时,这个数据会被原子的写回到主存。最终,cache的状态又会变为S

回到CAS。

我们一般说的CAS在x86的大概写法是

lock cmpxchg a, b, c 

对于一致性来讲,“lock”前缀是起关键作用的指令。而cmpxchg是一个原子执行“load”,“比较”,“save“的操作

我们看到TSL除了能够采取循环重试(忙等待)的方式去实现互斥,其实也能够使用阻塞等待的方式去实现互斥,因为忙等待采取的循环重试会,导致浪费CPU时间的问题,如果同步资源锁定时间很短,那么这个等待还是值得的,但是如果锁占用时间过长,那么自旋就会浪费CPU资源了,并且可能会导致优先级反转问题

1.2.等待-通知实现互斥

1.2.1.信号量(PV原语-互斥量)

其中具体原理见链接:

image-20210813175641881

1.2.2.基于等待-通知的互斥量

前面我们所说的TSL其实就是采取的互斥量的形式去实现互斥,只不过其实现的方式去循环重试,而这里我们使用等待通知机制去实现互斥量。

mutex互斥量:

  • mutex_lock:进行加锁,如果加锁时处于解锁状态(0表示解锁,其他值表示加锁,比1大的值表示加锁的次数),则调用成功;
  • mutex_unlock:进行解锁

其中互斥量可以通过TSL或者XCHG指令实现,下面是用户线程包mutex和mutex_unlock的代码

mutex_lock:
    TSL REGISTER,MUTEX    | 将互斥信号量复制到寄存器,并且将互斥信号量置为1
    CMP REGISTER,#0       | 互斥信号量是0吗?
    JZE ok                | 如果互斥信号量为0,它被解锁,所以返回
    CALL thread_yield     | 互斥信号量忙;调度其他线程
    JMP mutex_lock        | 稍后再试
ok:  RET                  | 返回调用者,进入临界区

mutex_unlock:
    MOVE MUTEX,#0         | 将mutex置为 0
    RET                   | 返回调用者

上述和TSL的区别:

以上代码和enter_region的区别?

  • enter_region失败的时候会始终重试,而这里会调度其他进程进行执行,这样迟早拥有锁的进程会进入运行并释放锁;
  • 在用户线程中,enter_region通过忙等待试图获取锁,将永远循环下去,绝对不会得到锁,因为其他线程不能得到运行进行释放锁。没有时钟停止运行时间过长的线程。

线程库无法像进程那样通过时钟中断强制线程让出CPU。在单核系统中如果一个线程霸占了CPU,那么该进程中的其他线程就无法执行了

1.3.管程

管程是一种语言概念,其组成包括条件变量、入口等待队列、条件变量等待队列,更像是一个程序,因此是一个语言概念,可以理解为管程其实就是等待-通知的互斥量的实现,例如Java的synchronized关键字,Lock+wait()+Conditon+notify/notifyAll实现。

**管程的互斥由编译器负责决定。**通用做法是使用互斥量二进制信号量

那么synchronized和Lock又有什么区别和联系呢?

相同:

  1. 两者都是基于互斥量实现临界区代码的互斥(互斥量的实现是基于底层硬件指令,让同时只有一个CPU去操作互斥量,其实现就包括TSL,本质上还是会让CPU无法中断,并且多CPU下临界区也只会在一个CPU上执行,因此互斥量,就是为了避免多个CPU上执行临界区的代码)
  2. 两者都可以实现管程模型,synchronized就是管程模型的提现,而Lock需要结合Condition、wait、notify
  3. 两者互斥区的锁都是基于阻塞锁(等待-通知),synchronized和Lock底层对互斥量的操作都是CPU的CAS指令(因为CAS能够保证对于变量修改的原子性,例如synchronized针对互斥量的修改)

区别

//TODO 了解完AQS再来看

  1. 拿锁的设计不同:synchronized临界区的互斥锁(重量级锁)是基于悲观锁,Lock是基于乐观锁CAS

2.Java线程采用的线程模型

2.1.常见的线程实现方式

2.1.1.使用内核线程实现

内核线程(KLT, Kernel-Level Thread):内核完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上

程序通过内核线程的高级接口:轻量级进程(LWP, Light Weight Process)操作内核线程,轻量级进程要消耗一定的内核资源如内核线程的栈空间,所以一个系统能够支持的轻量级进程的数量是有限的

image-20200223231207126

2.1.2.使用用户线程实现

互斥量的实现,就是基于用户线程的。用户线程基于用户空间的线程库上面,系统内核感知不到线程的实现。线程的创建、同步、销毁和调度都是在用户态中进行,无需内核的帮助

缺点:没有内核的帮忙,线程操作,线程阻塞同步处理起来是很麻烦的事情

image-20200223232011459

因此使用用户线程+轻量级进程混合实现

image-20200223232336679

2.2.Java的线程实现

JVM规范并没有限定Java线程需要那种模型,对于Windows和Linux版本使用的是1:1的线程,映射到轻量级进程中,就是用户线程+轻量级进程实现。

3.synchronized锁的使用场景

不要忘记Class也是一个类,也存在对象头!!!!

底层如何实现的呢?

我们可以写一个代码来看看其反汇编代码:

image-20200211170517235

可以发现,synchronized块最终变为了由monitorentermonitorexit包裹的反汇编指令语句块。

monitorenter:操作对象是一个reference对象,每个对象都与一个监视器关联,如果有其他线程获取了这个对象的monitor,当前的线程就要等待。每个对象的监视器有一个objectref条目计数器对象,成功进入监视器之后,监视器的objectref+1,然后,该线程就成为监视器的所有者了,同一个线程重复执行monitorenter,会重新进入监视器,并且objectref+1

monitorexit:操作对象是一个reference对象,执行该指令,objectref-1,直到objectref=0的时候,线程退出监视器,不再是对象所有者。

final操作:

Java中使用final字段的时候,JVM保证对象的使用者仅在构造完成后才能看到该字段的最终值,防止对象发布不正确。

为了达到这个目的,JVM会在final对象构造函数的末尾引入冻结操作,该操作可以防止对构造函数进行任何后续操作,或者进行指令重排。举个例子:

instance = new Singleton();

从宏观上看,可以认为将new分解为3个语句:

//final保证下述的执行顺序,而不会出现2 3两步互换位置的场景

reg0 = calloc(sizeof(Singleton));
reg0.<init>();//2
instance = reg0;//3

在给instance赋值前,确保<init>()构造方法限制下,保证了instance将得到最终值

首先看下对象在内存中的存储布局:

markwork中最低三位代表锁状态,第一位代表是否是偏向锁,2、3代表锁标志位,例如01代表存储的无锁状态的信息,00代表记录存储的轻量级锁的信息

image-20191201231334798

3.1.锁分类

  1. 无锁:对象头存储的是当前对象的hashcode,即原来的markword组成是001+hashcode

  2. 重量级锁:前面我们说了管程是通过互斥量实现,这样会导致线程挂起阻塞,这种传统的锁称为重量级锁。在JDK1.6之后引入了轻量级锁偏向锁的概念。为此,存在一个锁升级的过程。

  3. 轻量级锁(自旋锁):是与互斥量导致的线程挂起阻塞这种重量级锁对比的叫法,没错,就是比互斥重量级锁轻巧多了。原理是只要发生抢占,synchronized就会升级为轻量级锁,也就是不同的线程会通过CAS方式抢占markwork中抢占当前对象的指针,如果抢占成功,则把当前的线程id改成自己栈中锁记录的指针LR(LockRecord),因为是通过CAS的方式,所以也叫自旋锁

    这个时候你可能回想,无论变成什么锁,对象头都会发生改变,那之前对象头里面存储的hashcode会不会丢失啊?

    答案:不会,在发生锁的第一刻,他就会把原来的header存储在自己的线程栈中,所以不会丢失(因为考虑到CAS需要记录期望旧值)

  4. 偏向锁:数据无竞争的场景下,消除同步原语,进一步提高运算性能

    轻量级锁在无竞争情况下使用CAS消除同步使用的互斥量偏向锁在无竞争的情况把整个同步都消除了,更加轻量级。

3.2.锁升级

image-20200223233032942

img

  1. 升级为偏向锁、轻量级锁:线程1访问代码块并获得锁对象时,会在Java对象头markword和线程的栈帧记录偏向锁的ThreadID,因为偏向锁不会主动释放锁,存在线程再去获取锁时,需要比较当前的ThreadID和markword中存储的ThreadID是否一致,一致则还是线程1获得锁,不一致(其他线程,如线程2要竞争锁,而偏向锁不会主动释放锁因此还是存储的线程1的ThreadID),因此需要查看Java对象头markword记录的线程1是否存活,如果没有存活则被重置为无锁状态,其他线程(线程2)可以竞争将其设计为偏向锁,如果存活,那么立即查找该线程1的栈帧信息,如果还是需要继续持有这个锁对象。那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程

    总结:

    无锁->偏向锁:当前锁的对象头的markwork无锁状态且上锁成功(无竞争)

    偏向锁->无锁:当前锁存在竞争

    撤销偏向锁

    偏向锁的撤销需要等待全局安全点(Safe Point),在这个时间点上所有线程都处于暂停状态。如果持有偏向锁的线程还在同步块内,JVM 会将偏向锁升级为轻量级锁。具体做法是,在持有偏向锁线程的栈帧中创建锁记录(Lock Record),并通过 CAS 操作将对象头的 Mark Word 指向该锁记录。之后,持有偏向锁的线程可以继续在轻量级锁的状态下执行同步块中的代码,而不是中断执行去重新竞争。

  2. 升级(膨胀)为重量级锁:

    线程1获得轻量级别锁时,会把对象头markword复制一份到线程1的栈帧中创建的用于存储锁记录的空间(名称:DisplacedMarkWord),然后使用CAS把对象头markwork的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址,在并发场景下,如果线程2通过CAS修改markwork的记录失败则会自旋等待。此时如果线程2自旋次数达到阈值,或此时来了个线程3也来竞争这个锁,这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转

    为什么要存在轻量级锁?

    轻量级锁考虑的是竞争锁对象的线程不多,持有锁时间不长的情景。因为重量级锁的阻塞线程需要CPU从用户态转为内核态,代价比较高,如果刚阻塞不久锁就被释放了,就没必要了,因此可以考虑让线程阻塞变为自旋等待

    锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态

    重量级锁什么时候变成无锁

    重量级锁变为无锁状态主要是在持有锁的线程释放锁且没有其他线程竞争的情况下发生

  3. 轻量级锁解锁:持有锁的线程CAS尝试将当前锁对象头的markword替换为当前线程栈帧的Lock Record的值(其实就是无锁状态的的那些值),替换失败(也就是说markword中的锁状态已经变了,如果在释放锁时,CAS 操作失败,这意味着在该线程持有锁期间,有其他线程尝试竞争该锁,并且已经将对象头的状态修改为了偏向锁或重量级锁的状态(锁膨胀发生))则代表其他线程尝试获取锁,需要释放的同时wakeup被挂起的线程

    image-20210822205140737

锁优化

JDK 1.6版本花费了大量精力进行锁优化,包括:

  1. 自适应自旋锁:如果一个锁很少自旋成功,那么获得这个锁可能会去掉自旋阶段,如果自旋成功概率比较高,那么运行自旋等待持续时间相对较长

  2. 锁消除:这是即使编译器处理的,一般通过逃逸分析的数据支持进行锁消除,一般程序员都不会直接在单线程代码中显示的使用锁,但是有时候虽然只有一行代码:

    • str = "a" + "b" + "."
    • 但是在JDK5之前底层是翻译为了StringBuffer的append()操作,该方法是包含synchronized锁的,所以这种情况及时编译器还是会进行锁消除。
  3. 锁粗化:如果一些列连续的锁操作都是反复对同一个对象的加锁和解锁,并没有线程竞争,那么这个时候为了优化性能,会扩大锁的范围,例如下例如果每次执行sb.append都执行上锁解锁操作,那么非常损耗性能,因此会将锁粗化为针对while上锁,只上锁解锁一次

       /**
         * 锁粗化 lock coarsening
         *
         * @param str
         * @return
         */
        public String test(String str) {
            int i = 0;
            StringBuffer sb = new StringBuffer();
            while (i < 100) {
                sb.append(str);
                i++;
            }
            return sb.toString();
        }
    

4.各种锁的分类

4.1.乐观锁、悲观锁

4.2.自旋锁、阻塞锁

4.3.共享锁、排他锁

共享锁:读锁

排他锁:写锁

4.4.可重入锁

4.5.公平锁、非公平锁

4.6.可中断锁、不可中断锁

参考链接:

https://www.itzhai.com/articles/process-synchronization-and-lock.html

https://blog.csdn.net/tongdanping/article/details/79647337

https://blog.csdn.net/qq_39455116/article/details/104724265

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×