一文带你完全理解同步和锁的本质(干货)

谈到锁,离不开多线程,或者进程间的通讯。 为了更好地从底层原理去了解锁的机制,造成体系化的知识,这篇文章我会从进程间通讯底层原理提及,而后介绍一下Java中各类线程通讯的实现机制,最后作一个系统的总结。 html

还记得上次跟你撕逼内存模型的那我的吗,他又来了,而且向你甩出了一堆问题: java

image-20200225000533712
image-20200225000543895
image-20200225000553812

一、为何须要通讯

1.一、竞态条件

咱们知道,在操做系统中,互相协做的进程之间可能共享一些彼此都能读写的公共存储区,假设两个进程都须要改写这个公共的存储区那么就会产生竞争关系了。 程序员

下面举个例子 算法

假设两个进程a和b共享一个脱机目录,脱机目录中有许多槽位,free记录了下一个空的槽位,进程能够往下一个空槽位中写入内容。 数据库

进程a准备往下一个空槽位写入内容"test",进程b准备往下一个空槽位写入内容“good”。 编程

咱们来分析下极端状况: 后端

image-20200222215815129

能够发现,因为发生了时钟中断,两个进程都往槽位3写入了内容,进程b的内容被进程a的内容覆盖掉了。 bash

像这种因为两个或者多个进程读写某些共享数据,最后结果取决于进程运行的精确时序,称为竞态条件服务器

为了不这种竞态条件的出现,就须要找出存在这种竞态条件的程序片断,经过互斥的手段来阻止多个进程同时读写共享的数据。 网络

1.二、临界区

对共享内存进行访问的程序片断称为临界区

为了实现互斥而选择适当的原语是任何操做系统的主要涉及内容之一。 后面咱们会详细讨论各类实现互斥的手段,这些手段也是实现进程通讯或者线程通讯的技术基础。

仍是以上面的例子来讲明,为了不竞态条件的产生,咱们须要把获取空槽位和往槽位写内容的程序片断做为一个临界区,任何不一样的进程,不能够在同一个时刻进入这个临界区:

image-20200223095433203

如上图,进程b试图在a离开临界区以前进入临界区,会进入不了,致使阻塞,一般表现的行为为: 进程挂起或者自旋等待。

为了实现这种临界区的互斥,须要进程之间可以像对话同样,确认是否能够进入临界区执行代码,这种对话即进程通讯有不少经典的处理方法,下面咱们就逐个的来介绍。

二、常见的实现进程通讯的手段

2.一、忙等待的互斥(自旋等待)

所谓忙等待,指的是进程本身一直在循环判断是否能够获取到锁了,这种循环也称为自旋下面咱们经过屏蔽中断锁变量的介绍,依次引出忙等待的相关互斥手段方法。

2.1.一、屏蔽中断

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

image-20200223095140312

CPU只有发生时钟中断或其余中断才会进行进程切换,也就是说,屏蔽中断后,CPU不会切换到其余进程。 可是,这仅仅对执行disable的那个CPU有效,其余CPU仍将继续运行,也就是说多核处理器这种手段无效。

另外,这个屏蔽中断是用户进程触发的,若是用户进程长时间没有离开临界区,那就意味着中断一直启用不了,最终致使整个系统的终止。

因而可知,在这个多核CPU普及的时代,屏蔽中断并非实现互斥的良好手段。

2.1.二、锁变量

上面一种硬件的解决方案,既然硬件解决不了,那么咱们尝试经过软件层面的解决方案去实现。 咱们添加一个共享锁变量,变量为0,则表示可进入临界区,进入以后,设置为1,离开临界区重置为0,以下图所示:

image-20200223100855287

可是因为对Lock的check和set是分为两步,并不是原子性的,那么可能会出现以下状况:

image-20200223101930928

也就是说在进程a把Lock设置为1以前,b就进行check和set操做了,也获取到了Lock=0,致使两个进程同时进入了临界区。

这种非原子性的检查并设置锁操做仍是会存在竞态条件,并不能做为互斥的解决方案。

接下来咱们升级一下程序,为了不这种竞态条件,咱们让进程间严格轮换的方式去争抢使用Lock的机会。

2.1.三、严格轮换法

所谓严格轮换法,就是指定一个标识位turn,当turn=0的时候让进程a进入临界区,当turn=1的时候,让进程b进入临界区。 如下是实现代码:

1// 进程a 2while(TRUE){ 3    while(turn != 0);      /* 循环测试turn,看其值什么时候变为0 */ 4    critical_region();     /* 进入临界区 */ 5    turn = 1;              /* 让给下一个进程处理 */ 6    noncritical_region();  /* 离开临界区 */ 7} 8// 进程b 9while(TRUE){10    while(turn != 1);      /* 循环测试turn,看其值什么时候变为1 */11    critical_region();     /* 进入临界区 */12    turn = 0;              /* 让给下一个进程处理 */13    noncritical_region();  /* 离开临界区 */14}复制代码

这种方法可能致使在循环中不停的测试turn,这称为忙等待,比较浪费CPU,只有有理由认为等待时间是很是短的情形下,才使用忙等待,用于忙等待的锁,称为自旋锁(spin lock)。

假设如今进程a在临界区里面,而且执行了turn=1,准备把临界区轮换给进程b,可是这个时候进程b正在处理其余事情,那么这个临界区就一直被进程b阻塞了。 进程a想从新进入也须要等待。

也就是说,我唱完一首歌,把麦给了你,轮到你唱,这个时候你拿着麦去上厕所了。 那么我想唱歌,也只能等到你上完厕所,唱完歌,把麦的使用权交接给我,我才能够继续唱。

你上厕所居然影响到了我唱歌,就是所谓的临界区外运行的进程阻塞了其余想进入临界区的进程。

看来这种解决方案并非一个很好的选择。

接下来咱们经过一种G.L.Peterson发现的一种互斥算法来实现互斥功能。

2.1.四、Peterson解法

既然Linus说了Talk is cheap. Show me the code. 话很少说,咱们直接上代码:

1#define FALSE 0 2#define TRUE1 3#define N 2                       /* 进程数量 */ 4 5int turn;                         /* 如今轮到谁? */ 6int interested[N];                /* 全部值初始化为0 (FALSE) */ 7 8void enter_region(int process){   /* 进程是0或1 */ 9    int other;                    /* 其余进程号 */10    other = 1 - process;          /* 另外一方进程 */11    interested[process] = TRUE;   /* 表示所感兴趣 */12    turn = process;13    while(turn == process && interested[other] == TRUE);/* 空循环 */14}1516void leave_region(int process){17    interested[process] = FALSE;  /* 表示离开临界区 */18}复制代码

算法关键代码是while循环,若是并发执行,当进程0调用完enter_region以后,变量值以下:

1interested[0] = TRUE2turn=0复制代码

进程1调用完enter_region后,给turn赋值=1,覆盖了进程0的赋值:

1interested[0] = TRUE2interested[1] = TRUE3turn=1复制代码

而后进程1发现turn == process成立,而且interested[other] == TRUE,而后卡在这里自旋等待,直到另外一个进程离开了临界区。

能够看到,这个算法只适用于两个进程间的互斥处理,更多进程就没办法了。

接下来,咱们经过一种硬件指令的方式,帮助咱们更好的实现互斥。

2.1.五、基于硬件指令

基于硬件指令通常是基于冲突检测的乐观并发策略: 先进行操做,若是没有其余进程争用共享数据,操做就成功了,若是产生了冲突,就进行补偿,不断重试。

乐观并发策略须要硬件指令集的发展才能进行,须要硬件指令实现: 操做+冲突检的原子性。

这类指令有:

  • 测试并设置锁 Test and Set Lock  (TSL)

  • 获取并增长 Fetch-and-Increment

  • 交换 Swap

  • 比较并交换 Compare-and-Swap (CAS)

  • 加载连接/条件存储 Load-linked / Store-Conditional  LL/SC

2.1.5.一、TSL指令

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

TSL RX, LOCK

做用是将一个内存字lock读到寄存器RX,而后将lock设置为一个非0值。

执行原理: 执行TSL指令的CPU会锁住内存总线,禁止其余CPU在这个指令结束以前访问内存。

为了使用TSL指令,须要使用一个共享变量lock来协调多内存的访问。 lock=0时,任何进程均可以使用TSL指令将其设置为1,并读写共享内存,当操做结束时,进程使用move指令将lock的值从新设置为0。 下面是实现的关键代码:

1enter_region:2    TSL REGISTER,LOCK      | 复制锁到寄存器并将锁设为13    CMP REGISTER,#0        | 锁是0吗?4    JNE enter_region       | 若不是0,说明锁已被设置,因此循环5    RET                    | 返回调用者,进入临界区67leave_region:8    MOVE LOCK,#0           | 在锁中存入 09    RET                    | 返回调用者复制代码
image-20200223133446606

若是TSL原子操做没有成功,则从新跳转到enter_region方法循环执行。 这个跟Peterson算法有点相似,不过TSL能够支持任意多个进程的并发执行。 **

2.1.5.二、CAS指令

IA64 和 X86 使用cmpxchg指令完成CAS功能。

cas 内存位置 旧预期值 新值

CAS存在ABA问题,可使用版本号进行控制,保证其正确性。

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

硬件指令既能够实现忙等互斥,也能够实现进程挂起阻塞,关键看具体的实现代码,这里使用了JNE指令进行跳转循环等待,后面咱们会介绍用TSL指令实现进程挂起阻塞的互斥量。

2.二、睡眠与唤醒(进程挂起)

以上的解法都是能够实现互斥的,可是存在忙等,致使浪费CPU时间的问题,若是同步资源锁定时间很短,那么这个等待仍是值得的,可是若是锁占用时间过长,那么自旋就会浪费CPU资源了。 另外可能会致使优先级反转问题

以下图,进程H优先级较高,进程L先进入了临界区,而后H变到就绪状态,准备运行,如今H开始忙等待。 H就绪是L不会被调度到,不会离开临界区,因此H会永远等待下去:

image-20200223133911701

为了避免浪费CPU资源,咱们可使用进程间通讯的原语sleepwakeupsleep形成调用者阻塞,直到其余进程唤醒它。 下面咱们根据经典的生产者消费者的问题,引出信号量以及阻塞的概念。

2.2.一、生产者消费者问题

以下图,生产者往队列里面生产消息,消费者从队列里面取消息进行消费:

image-20200223143442359

当消息队列满的时候,生产者准备进行睡眠,但还没睡着:

image-20200223144249182

消费者消费了一条消息以后,他认为生产者正在睡觉,准备通知生产者也起床干活生产消息了:

image-20200223144606951

但是这个时候,生产者实际上都还没真正睡着,因此: wakeup信号丢失了!!!

wakeup信号丢失以后,生产者才真正的睡着了,这个时候消费者殊不知道生产者睡着了,因而一直在消费消息,知道消息消费完了,消费者本身也睡觉了。 最后两个进程都睡着了,世界清静了:

image-20200223145447834

生产者消费者完整代码以下:

1#define N 100                             /* 缓冲区中的槽数量 */ 2int count = 0;                            /* 缓冲区中的数据项数目 */ 3 4// 生产者 5void producer(void){ 6    int item; 7 8    while(TRUE){                          /* 无限循环 */ 9        item = produce_item()             /* 产生下一新数据项 */10        if(count == N) sleep();           /* 若是缓冲区满了,就进入休眠状态 */11        insert_item(item);                /* 将新数据放入缓冲区中 */12        count = count + 1;                /* 缓冲区数据项计数器+1 */13        if(count == 1) wakeup(consumer);  /* 缓冲区不为空则唤醒消费 */14    }15}1617// 消费者18void consumer(void){19    int item;2021    while(TRUE){                          /* 无限循环 */22        if(count == 0) sleep();           /* 若是缓冲区是空的,则进入休眠 */23        item = remove_item();             /* 从缓冲区中取出一个数据项 */24        count = count - 1                 /* 将缓冲区的数据项计数器-1 */25        if(count == N - 1) wakeup(producer); /* 缓冲区不满,则唤醒生产者? */26        consumer_item(item);              /* 打印数据项 */27    }28}复制代码

怎么解决这种进程之间不一样步,致使的死锁问题呢,接下来咱们就经过信号量来实现。

2.2.二、信号量

经过使用一个整型变量来累计唤醒次数,以供以后使用。 这个变量就是信号量。 能够经过如下方式操做这个变量:

  • down能够用sleep来表示,若是此刻信号量大于0,则将其值-1,若是=0,则进入进程进行睡眠;

  • up能够用wakeup来表示,up操做会使信号量+1,若是由于多个进程睡眠而没法完成先去的down操做,系统会选择一个进程唤醒并完成down操做,但信号量值还是0,取而代之的是睡眠进程数量减1;

检查数值、修改变量值以及可能发生的睡眠或者唤起操做是原子性的。

信号量原理:

检查数值、修改变量值以及可能发生的休眠或者唤起操做是原子性的,一般将up和down做为系统调用来实现;

当执行如下操做时,操做系统暂时屏蔽所有中断: 检查信号量、更新、可能发生的休眠或者唤醒,这些操做须要不多的指令,所以中断不会形成影响;

若是是多核CPU,信号量同时会被保护起来,经过使用TSL或者XCHG指令确保同一个时刻只有一个CPU对信号量进行操做。

使用信号量解决进程同步问题代码以下:

1#define N 100                   /* 缓冲区中的槽数目 */ 2typedef int semaphore;          /* 信号量是一种特殊的整型数据 */ 3semaphore mutex = 1;            /* 控制对临界区的访问 */ 4semaphore empty = N;            /* 计数缓冲区的空槽数目 */ 5semaphore full = 0;             /* 计数缓冲区的满槽数目 */ 6 7void producer(void){  8 9  int item;  1011  while(TRUE){                  /* TRUE是常量1 */12    item = producer_item();     /* 产生放在缓冲区中的一些数据 */13    down(&empty);               /* 将空槽数目-1  */14    down(&mutex);               /* 进入临界区  */15    insert_item(item);          /* 将新数据放入缓冲区中 */16    up(&mutex);                 /* 离开临界区 */17    up(&full);                  /* 将满槽数目+1 */18  }19}2021void consumer(void){2223  int item;2425  while(TRUE){                  /* 无限循环 */26    down(&full);                /* 将满槽数目-1 */27    down(&mutex);               /* 进入临界区 */28    item = remove_item();       /* 从缓冲区取出数据项 */29    up(&mutex);                 /* 离开临界区 */30    up(&empty);                 /* 将空槽数目+1 */31    consume_item(item);         /* 处理数据项 */32  }33}复制代码

如上,每一个进程在进入关键区域以前执行down操做,在离开关键区域以后执行up操做,这样就能够确保互斥了。 上面的代码其实是经过两种不一样的方式来使用信号量:

  • mutex用于互斥,确保任意时刻只有一个进程可以对缓冲区相关变量进行读写,互斥是用于避免进程混乱锁必须的一种操做;

  • fullempty经过计数,确保事件的发生或者不发生,这两个信号量的用意与mutex不一样。

2.2.三、互斥量

若是仅仅是须要互斥,而不是计数能力,可使用信号量的简单版本: mutex 互斥量。 通常用整型表示,经过调用:

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

  • mutex_unlock进行解锁。

互斥量能够经过TSL或者XCHG指令实现,下面是用户线程包的mutex_lockmutex_unlock的代码:

1mutex_lock: 2    TSL REGISTER,MUTEX    | 将互斥信号量复制到寄存器,而且将互斥信号量置为1 3    CMP REGISTER,#0       | 互斥信号量是0吗? 4    JZE ok                | 若是互斥信号量为0,它被解锁,因此返回 5    CALL thread_yield     | 互斥信号量忙;调度其余线程 6    JMP mutex_lock        | 稍后再试 7ok:  RET                  | 返回调用者,进入临界区 8 9mutex_unlock:10    MOVE MUTEX,#0         | 将mutex置为 011    RET                   | 返回调用者复制代码

以上代码和enter_region的区别?

  • enter_region失败的时候会始终重试,而这里会调度其余进程进行执行,这样早晚拥有锁的进程会进入运行并释放锁;

  • 在用户线程中,enter_region经过忙等待试图获取锁,将永远循环下去,绝对不会获得所,由于其余线程不能获得运行进行释放锁。 没有时钟中止运行时间过长的线程。

线程库没法像进程那样经过时钟中断强制线程让出CPU。 在单核系统中若是一个线程霸占了CPU,那么该进程中的其余线程就没法执行了。

因为thread_yield仅仅是一个用户空间的进程调度,因此它运行很是快捷。 这样mutex_lockmutex_unlock都不须要任何内核调用,从而实现了在用户空间中的同步,这个过程仅仅须要少许的同步。

有些线程包也会提供mutex_trylock,尝试获取锁或者失败,让调用方本身决定是等待下去仍是使用个替代方法。

2.三、管程

为了可以编写更加准确无误的程序,因而出现了管程(monitor)的概念:

管程是程序、变量和数据结构等组成的集合,构成一个特殊模块或者软件包,进程能够调用管程中的程序,可是不能在管程以外声明的过程当中直接访问管程内的数据结构

注意: 管程是语言概念而C语言不支持它。 下面是pascal语言描述的管程:

1monitor example 2    integer i; 3    condition c; 4 5    procedure producer(); 6    . 7    end; 8 9    procedure consumer();10    .11    end;12end monitor;复制代码

管程中任意时刻只能有一个活跃的进程,从而实现了互斥。 特定语言的编译器知道如何编译管程,翻译为底层的机器码: 当进程调用管程中的程序的时候,改程序的前面几条指令会检查管程中是否有其余活跃的进程,若是有,则挂起当前调用。

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

Java中的synchronized关键字正是基于管程实现的,咱们后面会具体介绍。

经过临界区的自动互斥,管程比信号量更容易保证并行编程的正确性可是管程是编程语言的概念,须要编译器识别并用某种方式对互斥作出保证,C语言就没有管程,因此不能依赖编译器来遵照互斥规则。

2.四、消息传递

不管是经过硬件指令,仍是信号量阻塞,或者管程,都是设计用来解决一个或者多个CPU上的互斥问题的。 可是在分布式系统中农,不一样服务器的CPU有本身的私有内存,经过网络相连,这些原语就会失效了。 因此须要其余方式来实现进程间的通讯,最多见的就是消息传递

2.五、屏障

这个是专门给进程组而不是进程间实现同步的。

有写程序中划分了若干阶段,而且规定,除非全部进程都准备就绪着手下一个阶段,不然任何进程都不能进入下一个阶段,能够在每一个阶段结尾安装屏障来实现这种行为,当一个进程达到屏障的时候,就会被屏障阻拦,直到全部进程都达到该屏障为止。

第2节内容主要提炼于《现代操做系统》一书,并添加了一些辅助理解的图片,给枯燥的描述添加一点趣味儿。

三、常见的Java线程实现通讯的手段和原理

线程通讯是创建在线程模型之上的,咱们首先来说一下Java的线程模型。

关于Java线程采用的线程模型

注意: 上面说的互斥量实现,是用户线程包中的实现,因此不须要内核调用。 而Java中的互斥量会有所不一样,仍是须要进行系统调用,由用户态切换到内核态。 JVM规范未指定Java线程须要用哪一种线程模型。

常见线程实现方式有如下几种:

使用内核线程实现

内核线程(KLT, Kernel-Level Thread)是直接由操做系统内核支持的线程,内核完成线程切换,内核经过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

各类线程操做,如建立、析构及同步,都须要进行系统调用,须要在用户态内核态中来回切换。

程序经过内核线程的高级接口: 轻量级进程(LWP, Light Weight Process)操做内核线程。 他们之间的关系以下:

image-20200223231207126

每一个轻量级进程都须要有一个内核线程的支持,所以轻量级进程要消耗必定的内核资源如内核线程的栈空间,因此一个系统可以支持的轻量级进程的数量是有限的。

内核线程至关于内核的分身,这样能够内核同时处理多件事情,支持多线程的内核叫多线程内核。

使用用户线程实现

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

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

image-20200223232011459

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

image-20200223232336679

Java线程的实现

JVM规范并无限定Java线程须要那种模型,对于Windows和Linux版本使用的是1:1的线程,映射到轻量级进程中。

3.一、synchronized关键字

经过使用synchronized,能够实现任意大语句块的原子性单位,使咱们可以解决volatile没法实现的read-modify-write问题。 底层命令能够参考我这篇文章: 2.三、同步操做Synchronized

Java中的synchronized关键字也是经过管程实现的,保证了操做单原子性和可见性。 在同一类中的synchronized方法,同一时刻只能有一个线程在调用。

3.1.一、synchronized锁特色

3.1.1.一、锁升级

管程通用作法是经过互斥量实现的,这会致使线程挂起阻塞,这种传统的锁称为重量级锁在JDK1.6以后引入了轻量级锁偏向锁的概念。 为此,存在一个锁升级的过程。

对象头中记录了对象的锁类型,咱们再来回顾一下对象的内存布局:

image-20191201231334798
image-20191201231334798

咱们先来介绍下轻量级锁和偏向锁。

轻量级锁

之因此叫轻量级锁,是与互斥量致使线程挂起阻塞这种重量级锁对比的叫法,没错,我就是比互斥重量级锁轻巧多了。

从未锁定到轻量级锁定的过程仍是有点繁琐的,涉及复制Mark WordCAS指定锁记录,指定失败的状况下可能还须要膨胀为重量级锁在释放锁的时候会CAS替换Mark Word,替换失败则说明有其余线程在等待获取锁,这个时候在释放锁的同时须要唤起其余线程。 我整理了一个图,方便更加直观的观察到这个过程Mark Word的变化:

image-20200223215127067
image-20200223215127067
偏向锁

所谓偏向锁,就是在数据无竞争的状况下,消除同步原语,进一步提升运行性能。

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

为何叫偏向锁,觉得偏爱呀,总是偏袒第一个获取到他的线程若是接下来的确没有其余线程竞争,那持有偏向锁的线程就永远不须要再进行同步了。

若是开启了偏向锁(JDK1.8默认是开启的),那么当锁对象第一次被线程获取的时候,虚拟机就会尝试设置为偏向锁模式:

image-20200223214657099
image-20200223214657099

一旦有其余线程竞争,那么偏向模式就结束了。 变为未锁定或者轻量级锁的状态。 他们之间的升级关系以下图所示:

image-20200223233032942
image-20200223233032942

3.1.1.二、锁优化

为了提升synchronized的性能,HotSpot虚拟机团队在JDK 1.6版本花费了大量精力进行锁优化,包括:

  • 自旋锁为了不互斥量频繁进行内核态切换带来的压力,引入了自旋锁。 默认会自旋10次试图获取锁,可使用参数设置: -XX:PreBlockSpin

  • 自适应自旋锁若是一个锁自旋不多成功,那么获取这个锁可能会去掉自旋阶段; 若是自旋获取成功几率比较高,那么运行自旋等待持续时间相对更长;

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

  • str = "a" + "b" + "."

  • 可是在JDK5以前底层是翻译为了StringBuffer的append()操做,该方法是包含synchronized锁的,因此这种状况及时编译器仍是会进行锁消除。

  • 锁粗化若是一些列连续的锁操做都是反复对同一个对象的加锁和解锁,并无线程竞争,那么这个时候为了优化性能,会扩大锁的范围。

固然,上面说起的锁升级,也是锁优化的一种手段。

3.1.1.三、可重入

对于同一个锁,若是一个线程成功进入了临界区,那么该线程在持有锁的同时,能够反复进入该锁。 synchronized锁的对象头markword会记录该锁的线程持有者和进入锁的次数的计数器。

每退出一个synchronized方法块,计数器就-1,直到0的时候就释放锁。

3.1.1.四、悲观锁

为何说它是一把悲观锁呢,由于假设有一个线程获取到了锁,那么其余尝试获取锁的线程只能等待,因而悲观的去睡觉了,等到别人叫醒以后才从新去竞争获取锁。

3.1.二、synchronized锁使用场景

3.二、理清各类锁的分类

咱们在各类文章书籍里面可能会看到对锁的各类分类,都是什么意思呢? 如今咱们就经过简短的描述来解释下,让你们有个形象的认识。

3.2.一、乐观锁和悲观锁

乐观锁

乐观锁老是很乐观的认为不会有太多人会抢占锁,因此通常不会先进行加锁,等到出了问题以后再处理。 典型的实现如CAS。

对于乐观锁,可能若是发现的确出现了问题,通常会经过自旋,或者直接放弃等的方式进行处理。

锁以乐观锁适用于并发写入少,大部分是读的场景。 这样就能够提升加快自旋的成功率了。

悲观锁

悲观锁就是很悲观的认为会有不少人想占用这个锁,悲观锁为了保证本身能够拿到锁,一上来就尝试锁定,若是锁不住,那就放弃了,直接睡觉去了,也就是线程挂起,等到下次有人叫他起床的时候,才会从新参与到锁的竞争中来。

对于竞争比较激烈,临界区消耗比较多的时间的场景,比较适合悲观锁。 无论临界区消耗多长的时间,也不会加大互斥锁的开销。

3.2.二、自旋锁和阻塞锁

自旋锁

这个概念,详细看完上文的你应该比较了解了,就是获取锁失败以后,循环重试。

阻塞锁

这个概念,详细看完上文的你应该比较了解了,就是获取锁失败以后,挂起线程。

3.2.三、共享锁和排他锁

共享锁

又称读锁,既然共享了,那么就不能随便删除和修改了。 否则人家好端端的看着你up给他的Java教学视频,忽然下一秒变成了动画片,叫人家怎么能接受呢?

排它锁

跟共享锁不同,排他锁就是一旦获取到了他以后,其余线程就不再能获取到了。 既然别人获取不到了,那么获取到排他锁的我就能够随意的进行修改内容了。

3.2.四、可重入锁

可重入锁

这个比较容易理解,咱们在上面讲synchronized的时候已经介绍了。

可重入锁在反复屡次使用同一个锁的场景下,避免了死锁的发生。

3.2.五、公平锁和非公平锁

公平锁

公平锁,就是彻底按照请求顺序来分配的锁,保证了对全部线程公平。

非公平锁

跟公平锁不同,是不彻底按照请求顺序来处理的。

Java并发包中的ReentrantLock锁就提供了非公平锁和公平锁的实现。

为何要有公平锁呢? 设想一下,咱们到银行办理业务取票了,若是无论发生什么状况,彻底按照顺序来办理,这就是公平锁。

可是轮到一个号以后,假如那个号的人恰好去外面买东西了,若是你们要继续等它回来办理,就会很花时间,因而索性让人去抢柜台窗口,先抢到的人就先办理,若是连续两次都抢位失败了,那么咱们就把这我的放入排队队列,等到抢到窗口的人办理完了业务,再轮流叫唤他们,这就是非公平锁。

可是若是窗口的那我的办理业务的时间好久,忽然叫一大波人冲上来抢窗口,是抢不到的呀,也就是说对于业务执行时间很长的场景,非公平锁其实效率并不高。

很明显,公平锁吞吐量小,但能够保证每一个线程在等一段时间总有机会执行;

而非公平锁吞吐量更大,可是可能有些线程会长时间得不到执行。

3.2.六、可中断锁和不可中断锁

可中断锁

能够响应线程中断的锁,如ReentrantLock.lockInterruptibly()。

不可中断锁

不能够响应线程中断的锁,如ReentrantLock.lock()。


其实Java并发包中针对不一样的使用场景,也提供了不少的锁,咱们能够直接拿来用。 关于其实现思路,因为篇幅所限,我打算后面单独放到一篇文章中讲解,包括实现原理,使用场景等。 相信有了本文这些底层知识以后,在看其余顶层的实现,都会驾轻就熟点。

四、结语

好了,咱们今天就讲到这里了,可以看到这里的朋友们是真的很热爱技术,想对大家说: 加油,你们都是最棒的!

本文为arthinking基于相关技术资料和官方文档撰写而成,确保内容的准确性,若是你发现了有何错漏之处,烦请高抬贵手帮忙指正,万分感激。

你们能够关注个人博客: itzhai.com 获取更多文章,我将持续更新后端相关技术,涉及JVM、Java基础、架构设计、网络编程、数据结构、数据库、算法、并发编程、分布式系统等相关内容。

若是您以为读完本文有所收获的话,能够关注个人帐号,或者点个赞码字不易,你的支持很重要。

关注个人公众号,及时获取最新的文章。

References

《现代操做系统》

《深刻理解Java虚拟机: JVM高级特性与最佳实践》

聊聊 Java 的几把 JVM 级锁

Java并发编程—细说J.U.C下Lock的分类及特色详解(结合案例和源码)


本文做者: arthinking

博客连接: https://www.itzhai.com/cpj/process-synchronization-and-lock.html

一文带你完全理解同步和锁的本质(干货)

版权声明: BY-NC-SA许可协议: 创做不易,如需转载,请务必附加上博客连接,谢谢!


相关文章
相关标签/搜索