线程安全——锁

什么是锁?

锁是在执行多线程时用于强行限制资源访问的同步机制,即用于在并发控制中保证对互斥要求的满足。

锁分类

  • 从线程是否需要对资源加锁可以分为 悲观锁乐观锁
  • 从资源已被锁定,线程是否阻塞可以分为 自旋锁
  • 从多个线程并发访问资源,也就是 Synchronized 可以分为 无锁偏向锁轻量级锁重量级锁
  • 从锁的公平性进行区分,可以分为 公平锁非公平锁
  • 从根据锁是否重复获取可以分为 可重入锁不可重入锁
  • 从那个多个线程能否获取同一把锁分为 共享锁排他锁

悲观锁

悲观锁 是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把 资源 或者 数据 锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。 悲观锁的实现往往依靠数据库本身的锁功能实现。

Java 中的 SynchronizedReentrantLock 等独占锁(排他锁)也是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人拿走。

乐观锁

乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过(具体如何判断我们下面再说)。

  1. 乐观锁的实现方式

    乐观锁的实现方案一般来说有两种: 版本号机制CAS实现 。乐观锁多适用于多度的应用类型,这样可以提高吞吐量。

    在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

    版本号机制: 版本号机制是在数据表中加上一个 version 字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1,当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

    CAS 算法: CAS 即 compare and swap(比较与交换),是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization) java 的 cas 利用的的是 unsafe 这个类提供的 cas 操作。

  2. CAS 的骚操作

    CAS 中涉及三个要素

    • 需要读写的内存值 V

    • 进行比较的值 A

    • 拟写入的新值 B

      当一个线程从内存中读取的值为 V,将当前值 A 与 V 比较,如果相等,则将 B 写入内存;如果不相等,则代表中间由线程修改过这个值,这样我们就要去重复一开始的操作。

      java8 对 CAS 的优化

      线程太多,就会出现一个问题:V 的值老是被修改了,所以线程要一值重复操作。白白着在那里循环消耗资源。

      为了解决这个问题,Java8 引入了一个 cell[] 数组,它的工作机制是这样的:假如有 5 个线程要对 i 进行自增操作,由于 5 个线程的话,不是很多,起冲突的几率较小,那就让他们按照以往正常的那样,采用 CAS 来自增吧。

      但是,

      如果有 100 个线程要对 i 进行自增操作的话,这个时候,冲突就会大大增加,系统就会把这些线程分配到不同的 cell 数组元素去,假如 cell[10] 有 10 个元素吧,且元素的初始化值为 0,那么系统就会把 100 个线程分成 10 组,每一组对 cell 数组其中的一个元素做自增操作,这样到最后,cell 数组 10 个元素的值都为 10,系统在把这 10 个元素的值进行汇总,进而得到 100,二这,就等价于 100 个线程对 i 进行了 100 次自增操作。

  3. 乐观锁的缺点

    ABA 问题

    ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?

    JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

    也可以采用 CAS 的一个变种 DCAS 来解决这个问题。 DCAS,是对于每一个 V 增加一个引用的表示修改次数的标记符。对于每个 V,如果引用修改了一次,这个计数器就加 1。然后再这个变量需要 update 的时候,就同时检查变量的值和计数器的值。

    循环开销大:

    我们知道乐观锁在进行写操作的时候会判断是否能够写入成功,如果写入不成功将触发等待 -> 重试机制,这种情况是一个自旋锁,简单来说就是适用于短期内获取不到,进行等待重试的锁,它不适用于长期获取不到锁的情况,另外,自旋循环对于性能开销比较大。

自旋锁

自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为 自旋锁(spinlock)

  1. 自旋锁的原理

    如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。

  2. 自旋锁的缺点

    如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX;同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。

适应性自旋锁

解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。但是如何去选择自旋时间呢?

如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!

JDK 在 1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

无锁

无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。

偏向锁

大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。

轻量级锁

轻量级锁是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能,下面是详细的获取过程。

重量级锁

轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转。

公平锁

表示线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的 FIFO 先进先出顺序。公平锁可以由 ReentrantLook 实现。

非公平锁

就是一种获取锁的抢占机制,是随机获得锁的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。

可重入锁

可重入锁又称为递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。Java 中 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是在一定程度上可以避免死锁。

1
2
3
4
5
6
7
8
private synchronized void doSomething(){
System.out.println("doSomething...");
doSomethingElse();
}

private synchronized void doSomethingElse(){
System.out.println("doSomethingElse...");
}

在上面这段代码中,我们对 doSomething() 和 doSomethingElse() 分别使用了 synchronized 进行锁定,doSomething() 方法中调用了 doSomethingElse() 方法,因为 synchronized 是可重入锁,所以同一个线程在调用 doSomething() 方法时,也能够进入 doSomethingElse() 方法中。

不可重入锁

如果 synchronized 是不可重入锁的话,那么在调用 doSomethingElse() 方法的时候,必须把 doSomething() 的锁丢掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。

也就是说,不可重入锁会造成死锁

独占锁

独占锁又叫做排他锁,是指锁在同一时刻只能被一个线程拥有,其他线程想要访问资源,就会被阻塞。JDK 中 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。

共享锁

共享锁指的是锁能够被多个线程所拥有,如果某个线程对资源加上共享锁后,则其他线程只能对资源再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

我们看到 ReentrantReadWriteLock 有两把锁:ReadLock 和 WriteLock,也就是一个读锁一个写锁,合在一起叫做读写锁。

文章目录
  1. 1. 什么是锁?
  2. 2. 锁分类
  3. 3. 悲观锁
  4. 4. 乐观锁
  5. 5. 自旋锁
  6. 6. 适应性自旋锁
  7. 7. 无锁
  8. 8. 偏向锁
  9. 9. 轻量级锁
  10. 10. 重量级锁
  11. 11. 公平锁
  12. 12. 非公平锁
  13. 13. 可重入锁
  14. 14. 不可重入锁
  15. 15. 独占锁
  16. 16. 共享锁
|