线程的相关的锁(进阶)

目录

常见的锁的策略

悲观锁和乐观锁

                  读写锁 

重量级锁 vs 轻量级锁 

公平锁和非公平锁 

可重入锁 vs 不可重入锁  

CAS 

CAS的应用 

CAS引发的ABA问题

synchronized背后的锁的流程 

JUC(java.util.concurrent) 的常见类 

synchronized和lock的区别 

死锁

产生死锁的四个必要条件


常见的锁的策略

  • 注意: 接下来讲解的锁策略不仅仅是局限于 Java . 任何和 "锁" 相关的话题, 都可能会涉及到以下内容. 这些特性主要是给锁的实现者来参考的. 普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的.

悲观锁和乐观锁

  • 乐观锁:每次读写数据都认为不会发生冲突,线程不会阻塞,一般来说。只有进行数据更新时才会检查是否发生冲突,若没有冲突,直接更新,只有多个线程都在更新数据,才会解决冲突问题(若线程冲突不严重的时候,可以采用乐观锁策略来避免多次的加锁解锁操作)
  • Java中的乐观锁:CAS,比较并替换,比较当前值(主内存中的值),与预期值(当前
    线程中的值,主内存中值的一份拷贝)是否一样,一样则更新,否则继续进行CAS操作
  • 悲观锁:每次去读取数据都会发生冲突(与其他线程读数据),每次在进行数据读写都会上锁(互斥),保证同一时间段只有一个线程只有一个线程在读写数据(当线程冲突严重的时,就需要加锁,来避免线程频繁访问共享数据失败带来的CPU空转问题)
  • Java中的悲观锁:synchronized修饰的方法和方法块、ReentrantLock。

乐观锁并不是真正把线程阻塞了,乐观锁的实现一般都会采用版本号进制来实现 

  • 乐观锁并不是真正把线程阻塞了,乐观锁的实现一般都是采用版本进制来实现
  • 核心是线程是否能够成功刷新主内存的值,当工作内存的版本号==主存的版本号才能更新成功,同步刷新自己的版本号和主内存的版本号,表示此时更新成功
  • 一般锁都是实现乐观锁和悲观锁并用的策略,synchronized最开始就是乐观锁,当竞争激烈再升级为悲观锁 

如果写入失败的处理方法

  1. 就从主存中读取最新的版本号到工作内存,然后尝试再最新的数据上进行操作,若最后写入成功,那么主存和工作内存的版本号都+1(CAS策略,不断重试写回,直到成功为止)
  2. 直接报错,线程2退出,不写回

乐观锁和悲观锁的理解及如何实现,有哪些实现方式

  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。
  • 乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
     

读写锁 

多线程之间,并发读取数据不会有线程安全问题,只有再更新数据(增删改)时会有线程安全问题。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。

  • 多个线程并发访问读锁(读数据),则多个线程都能访问到读锁,读锁和读锁是并发的,不互斥
  • 两个线程都需要访问写锁,则这两个线程互斥,只有一个线程能成功获得锁,其他线程阻塞
  • 当一个线程读,一个线程写(也互斥,只有写线程结束,读线程才能继续)

JAVA的读写锁

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.synchronized不是读写锁

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行 加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进 行加锁解锁.

重量级锁 vs 轻量级锁 

锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

  • CPU 提供了 "原子操作指令".
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.
  • 锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

重量级锁: 加锁机制重度依赖了 OS 提供了 mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度
  • 重量级锁需要操作系统和硬件支持,线程获取重量级锁失败进行阻塞状态(OS,用户态切换到内核态,开销非常大)

这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田".

轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.

  • 少量的内核态用户态切换.
  • 不太容易引发线程调度.
  • 轻量级锁 尽量在用户态执行操作,线程不阻塞,不会进行状态切换

 synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.

 

自旋锁

(之前的方式,获取锁失败的线程就会进行Blocked状态),线程置入阻塞队列,等待锁被释放,由CPU唤醒(这个时间一般来说都比较长,用户态到核心态的切换)

  • 一般轻量级锁的常见实现采用自旋锁
  • 自旋锁(自旋就是循环)
  • 自旋锁是一种典型的 轻量级锁 的实现方式.
  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的).
  • synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.

公平锁和非公平锁 

公平锁:获取失败的线程进入阻塞队列,当锁被释放,第一个进入阻塞队列的线程首先获得到锁(等待时间最长的线程获得锁)

非公平锁:获取失败的线程进入阻塞队列,当锁被释放,阻塞队列的所有线程都有可能获得到锁(不一定是等待时间最长的线程获得)

  • 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要 想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  • 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
  • synchronized 是非公平锁.

可重入锁 vs 不可重入锁  

 可重入

  • Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。
  • 而 Linux 系统提供的 mutex 是不可重入锁
  • 可重入的意思就是获取的对象锁的线程可以再次加锁
  • 可重入锁的字面意思是可以重新进入的锁,即允许同一个线程多次获取同一把锁
  • 比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入 (因为这个原因可重入锁也叫做递归锁

  •  按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁.
  • 可重入锁可以防止这种死锁问题

synchronized支持线程的可重入

  1. Java中每个对象都有一个对象头(描述当前对象的锁信息,当前对象被哪个线程所拥有,以及一个计数器——当前对象被上锁的次数)
  2. 若线程1需要进入当前对象的同步代码块(sychronized),此时当前对象的对象头没有锁信息

CAS 

  1. Compare and Swap比较交换,不会真正的阻塞线程,不断尝试更新,是乐观锁的一种实现方式

原理

  • 那有没有可能我在判断了 线程1的A为0为之后,正准备更新它的新值的时候,被其它线程更改了 i 的值呢?不会的。因为CAS是⼀种原⼦操作,它是⼀种系统原语,是⼀条CPU的原⼦指令,从CPU层⾯保证它的原⼦性
     

CAS的应用 

1使用CAS实现原子类

  • 标准库中提供了 java.util.concurrent.atomic , 里面的类都是基于这种方式来实现的.

比如要实现i++或者i--的原子操作,又不想上锁,就使用atomic这个包下的原子类实现

无锁实现线程安全

 

Atomic的原理

  • Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。

 2使用CAS来实现自旋锁

  • 自旋锁指的是获取锁失败的线程并不会进入阻塞态,而是在CPU上空转(线程不出CPU,跑一些无用的指令),不断查询当前的锁的状态

  • CAS(V,A,B),V表示当前锁的拥有者 A表示希望拥有这个锁的线程对象 B表示当前的线程
  • 当A==NULL的时候,表示当前自旋锁没有被任何线程拥有,尝试将this.owner==Thread.currerntThread() ,将持有锁的对象变成当前线程 
  • 那有没有可能我在判断了 i 为5之后,正准备更新它的新值的时候,被其它线程更
    改了 i 的值呢?不会的。因为CAS是⼀种原⼦操作,它是⼀种系统原语,是⼀条CPU的原⼦指令,从CPU层⾯保证它的原⼦性
     

CAS引发的ABA问题

  • 正常情况下有两个线程t1和t2,同时修改共享变量num,其中一个线程将num值改为正确值,另一个线程在修改的时候num!=A,另一个线程的工作内存的值已经过期了,因此无法修改

特殊情况

导致的问题 

 ​​​​​

解决的方法 

  • 在ABA问题中引入版本号
  • 当CAS V==A的时候,才会用到版本号,来判断当前的主内存的V是否被别的线程反复修改过

CAS 的会产生什么问题

  • ABA 问题:

比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。从Java1.5 开始 JDK 的 atomic包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。

  • 循环时间长开销大:

对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。

  • 只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
 

synchronized背后的锁的流程 

  • 将锁信息放入对象头中 
  • 1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  • 2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  • 3. 实现轻量级锁的时候大概率用到的自旋锁策略
  • 4. 是一种不公平锁
  • 5. 是一种可重入锁
  • 6. 不是读写锁

偏向锁 

  1. 偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
  2. 实现原理:⼀个线程在第⼀次进⼊同步块时,会在对象头和栈帧中的锁记录⾥存储锁的偏向的线程ID。当下次该线程进⼊这个同步块时,会去检查锁的Mark Word⾥⾯是不是放的⾃⼰的线程ID
     

举个栗子理解偏向锁
假设男主是一个锁 , 女主是一个线程 . 如果只有这一个线程来使用这个锁 , 那么男主女主即使不领证 结婚( 避免了高成本操作 ), 也可以一直幸福的生活下去 .
但是女配出现了 , 也尝试竞争男主 , 此时不管领证结婚这个操作成本多高 , 女主也势必要把这个动作完成了, 让女配死心
轻量级锁.
  • 使用CAS(自旋锁)来实现轻量级的锁的获取,有加锁和解锁的过程
  • Java语言层次进行循环
  • 其他线程都是靠自旋的方式来等待执行的线程释放锁
重量级锁
  • 这就是悲观锁了,线程会真正的进入阻塞态
  • 此处的重量级锁就是指用到内核提供的 mutex .

说一下 synchronized 底层实现原理(不带优化的)
Synchronized的语义底层是通过一个monitor(监视器锁)的对象来完成,每个对象有一个监视器锁(monitor),和计数器。每个Synchronized修饰过的代码当它的monitor被占用时就会处于锁定状态并且尝试获取monitor的所有权 

  • 1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  • 3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
并发关键字 synchronized 
  • 在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized 代码段不被多个线程同时执行。synchronized 可以修饰类、方法、变量。
  • 另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高
  • 庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
     

多线程中 synchronized 锁升级的原理是什么

  • synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

  • 偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些CAS操作(比如等待队列的一些CAS操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,轻量级锁就会升级为重量级锁;重量级锁是synchronized ,是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。

JUC(java.util.concurrent) 的常见类 

  1. 对象锁 juc.lock,JDK1.0就有,需要JVM借助操作系统提供的mutex系统原语来实现
  2. java.util.concurrent中的Lock接口也可以实现对象锁,JDK1.5之后Java语言自己实现的互斥锁,不需要借助系统的monitor机制

ReentrantLock 的用法:
  1. lock(): 加锁, 如果获取不到锁就死等.
  2. trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
  3. unlock(): 解锁

synchronized和lock的区别 

  • 两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
  • synchronized 是一个关键字, JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, JVM 外实现的(基于 Java 实现).
  • ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock (time)的方式等待一段时间就 放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启 公平锁模式
  • synchronized不支持读写锁,Lock的子类ReentrantReadWriteLock支持读写锁
  • 二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word

如何选择

  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 如果需要使用公平锁, 使用 ReentrantLock.

Lock 接口和synchronized 对比同步它有什么优势
Lock 接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。
它的优势有:

  • (1)可以使锁更公平
  • (2)可以使线程在等待锁的时候响应中断
  • (3)可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
  • (4)可以在不同的范围,以不同的顺序获取和释放锁
  • 整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。
     

死锁

死锁是什么
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线 程被无限期地阻塞,因此程序不可能正常终止。
  • 因为第一个线程占用着项目资源,然后想要唱歌资源,第二个线程占着唱歌资源,想要项目资源,陷入死循环,这就是死锁

哲学家进餐问题

产生死锁的四个必要条件

  •  1互斥条件,线程程对所分配到的资源是排他性使用的,在一段时间内,某资源只能被一个线程占用
  • 2 请求和保持条件 进程已经占用了一个资源,但又提出了新的资源请求,但是被请求的资源已经被其他线程占用,此时请求线程被阻塞,但是又不会去释放自己已经拥有的资源(吃着自己碗里的,还看着别人的)
  • 3不可抢占条件 线程已经获得的资源在未使用完之前不能被其他进程所抢占,只能自己完成任务后释放
  • 4循环等待条件 就如上面的哲学家进餐问题一样,发生死锁时,必定会存在一个“进程-资源循环链” 但是存在死锁必定存在循环等待链,但是存在循环等待链不一定存在死锁,可能同类的资源不止一个

只要破坏掉其中一个条件就可以解决死锁,最容易破坏的条件就是循环等待

破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3...M).N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁..它必须按照确定的顺序获取它们。它不能获取序列后面的锁,除非它获得了前面的锁。这样就可以避免路等待

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

库里不会投三分

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值