聊聊并发(七)——锁

一、乐观锁和悲观锁

1、乐观锁

  乐观锁只是一种设计思想,并不是真的有一种锁是乐观的。
  思想:每次操作共享数据之前,都认为其他线程不会修改数据,所以都不获取锁,直接操作。只在最后更新的时候会判断一下在此期间是否有其他线程更新过这个数据。其实是一种无锁状态的更新。
  典型实现:数据库版本号;CAS算法。

2、悲观锁

  悲观锁只是一种设计思想,并不是真的有一种锁是悲观的。
  思想:每次操作共享数据之前,都认为其他线程会修改数据,所以都先获取锁,才操作。未获得锁的线程,必须阻塞等待。
  典型实现:synchronized;ReentrantLock。

二、共享锁和排他锁

1、介绍

  对数据的访问通常分为两种情况,读(查询)和写(新增、修改、删除)。
  多个线程并发读数据,是不会出现问题的。但是,多个线程并发写数据,到底是写入哪个线程的数据呢?这就是平时所说的线程同步问题。
  所以,写写/读写需要互斥访问,读读不需要互斥访问。

2、排他锁(写锁)

  排他锁(X锁),又称写锁、独占锁、互斥锁:锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得排他锁的线程即能读数据又能修改数据。
  理解:一个线程获取写锁,其对数据可读,可写。其他线程只能等待,读,写都不可以。即:写写/读写需要互斥访问。
  显然:synchronized 和 Lock 的实现类就是排他锁。

3、共享锁(读锁)

  共享锁(S锁),又称读锁:一种只读的数据锁,可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加排他锁。获得共享锁的线程只能读数据,不能修改数据。
  理解:一个线程获取读锁,其对数据只可读,不可写。其他线程可以再获取读锁,但不可获取写锁。即:读写需要互斥访问,读读不需要互斥访问。

4、应用

  问题:若对一个共享数据,加了 synchronized 排他锁(互斥锁),而对数据的访问又仅仅只是读。那么,势必会影响读的效率。
  原因:一次只能被一个线程访问,未获取到锁的线程则必须等待。即:A读完,B才能读,B读完,C才能读。

  解决:可以用读写锁来提高效率。在 JUC 包中,ReadWriteLock 就维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读锁可以由多个 reader 线程同时保持。读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是可以同时有多个线程并发读。
  读锁,可以多个线程并发的持有。
  写锁,是独占的。
  源码示例:读写锁

1 public interface ReadWriteLock {
2     // 返回一个读锁(共享锁)
3     Lock readLock();
4 
5     // 返回一个写锁(排他锁)
6     Lock writeLock();
7 }

  这也是,synchronize与Lock的异同之一。

三、公平锁和非公平锁

1、介绍

  公平锁:多个线程获取锁的顺序,是按照它们发出请求的顺序来的。
  非公平锁:多个线程获取锁的顺序,是随机的。谁抢到是谁的。

2、比较

  效率:显然,非公平锁,效率高;公平锁,效率相对低。
  问题:非公平锁,大家自己抢锁,会导致一些一直抢不到锁的线程饿死(线程饥饿:线程因长时间得不到CPU执行权,导致一直得不到执行的现象);公平锁,可以保证所有的线程都会得到执行。
  典型实现:synchronized 是非公平锁。Lock 默认是非公平锁,也可以通过构造器参数 new 一个公平锁。
  源码示例:ReentrantLock 构造器

1 // 默认构造器是 new 一个非公平锁.
2 public ReentrantLock() {
3     sync = new NonfairSync();
4 }
5 
6 // 根据参数确定创建公平锁还是非公平锁.
7 public ReentrantLock(boolean fair) {
8     sync = fair ? new FairSync() : new NonfairSync();
9 }

3、演示

  代码示例:公平锁与非公平锁

 1 // 不写注释也能看懂的代码
 2 public class Main {
 3     public static void main(String[] args) {
 4         final LockDemo lockDemo = new LockDemo();
 5         Thread thread1 = new Thread(lockDemo, "线程A");
 6         Thread thread2 = new Thread(lockDemo, "线程B");
 7         Thread thread3 = new Thread(lockDemo, "线程C");
 8 
 9         thread1.start();
10         thread2.start();
11         thread3.start();
12     }
13 }
14 
15 class LockDemo implements Runnable {
16 
17     // 这里使用的是 非公平锁
18     private final ReentrantLock lock = new ReentrantLock();
19 
20     @Override
21     public void run() {
22         while (true) {
23             lock.lock();
24 
25             try {
26                 System.out.println(Thread.currentThread().getName() + " 获取到了锁~");
27             } finally {
28                 lock.unlock();
29             }
30         }
31     }
32 }
33 
34 // 非公平锁:结果(截取一部分)
35 线程A 获取到了锁~
36 线程A 获取到了锁~
37 线程A 获取到了锁~
38 线程A 获取到了锁~
39 线程A 获取到了锁~
40 线程A 获取到了锁~
41 线程C 获取到了锁~
42 线程C 获取到了锁~
43 线程C 获取到了锁~
44 线程C 获取到了锁~
45 
46 
47 // 修改为公平锁
48 private final ReentrantLock lock = new ReentrantLock(true);
49 
50 // 公平锁:结果(截取一部分)
51 线程A 获取到了锁~
52 线程B 获取到了锁~
53 线程C 获取到了锁~
54 线程A 获取到了锁~
55 线程B 获取到了锁~
56 线程C 获取到了锁~

  可以发现:非公平锁,获取锁是随机的。公平锁,获取锁顺序是依次的,ABC,或者BCA,或者CAB。

四、可重入锁(递归锁)

  可重入锁,又称递归锁,是指同一个线程在外层方法获取了锁,再进入内层方法会自动获取锁。

  典型实现:synchronized 和 ReentrantLock 都是可重入锁。可重入锁的好处是可一定程度避免死锁。

1、设计可重入锁

  代码示例:一种可重入锁

 1 public class Lock {
 2 
 3     // 是否被锁
 4     private boolean locked = false;
 5 
 6     // 当前持有锁的线程
 7     private Thread ownerThread;
 8 
 9     // 锁状态标志
10     private int state;
11 
12     public synchronized void lock() throws Exception {
13         final Thread thread = Thread.currentThread();
14         while (locked && ownerThread != thread) {
15             wait();
16         }
17 
18         locked = true;
19         // 每重入一次 标志 +1
20         state++;
21         ownerThread = thread;
22     }
23 
24     public synchronized void unLock() {
25         if (Thread.currentThread() == this.ownerThread) {
26             state--;
27             // 表示完全释放锁
28             if (state == 0) {
29                 locked = false;
30                 notify();
31             }
32         }
33     }
34 }
一种可重入锁

2、设计不可重入锁

  不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,会因获取不到而阻塞。
  代码示例:一种不可重入锁

 1 public class Lock {
 2 
 3     // 是否被锁
 4     private boolean locked = false;
 5 
 6     public synchronized void lock() throws Exception {
 7         // 如果已经被锁了,就等待
 8         while (locked) {
 9             wait();
10         }
11 
12         locked = true;
13     }
14 
15     public synchronized void unLock() {
16         locked = false;
17         notify();
18     }
19 }

五、自旋锁

1、介绍

  并不是一把锁,也只是一种思想。所谓自旋,就是失败了,不断尝试。线程并不是被直接阻塞,而是执行一个忙循环,这个过程叫自旋。

  对CAS算法不了解的,可以先看这篇CAS算法

2、优缺点

  优点:减少线程被挂起的几率,线程的挂起和唤醒也需要消耗资源。
  缺点:若一个线程占用的时间比较长,导致其他线程一直失败,一直循环,忙循环浪费系统资源,就会降低整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。

3、手写一个自旋锁

  代码示例:手写一个自旋锁

 1 public class SpinLock {
 2 
 3     AtomicReference<Thread> lock = new AtomicReference<>();
 4 
 5     // 上锁
 6     public void lock() throws Exception {
 7         final Thread t = Thread.currentThread();
 8 
 9         // 通过CAS算数将 null --> 当前线程. 成功表示获取到锁. 否则自旋
10         while (!lock.compareAndSet(null, t)) {
11 
12         }
13     }
14 
15     // 释放锁
16     public void unLock() {
17         final Thread t = Thread.currentThread();
18 
19         // 释放当前线程的锁
20         lock.compareAndSet(t, null);
21     }
22 }

4、自适应自旋锁

  在 JDK1.6 引入了自适应自旋。自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。
  如果虚拟机认为这次自旋很有可能成功,那就会持续较多的时间;如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。

六、锁升级(无锁|偏向锁|轻量级锁|重量级锁)

  见《深入理解Synchronized》

posted @ 2021-12-09 23:16  Craftsman-L  阅读(407)  评论(0编辑  收藏  举报