Java并发包——线程同步和锁

Java并发包——线程同步和锁

摘要:本文主要学习了Java并发包里有关线程同步的类和锁的一些相关概念。

部分内容来自以下博客:

https://www.cnblogs.com/dolphin0520/p/3923167.html

https://blog.csdn.net/tyyj90/article/details/78236053

线程同步方式

对于线程安全我们前面使用了synchronized关键字,对于线程的协作我们使用Object.wait()和Object.notify()。在JDK1.5中java为我们提供了Lock来实现与它们相同的功能,并且性能优于它们,在JDK1.6时,JDK对synchronized做了优化,在性能上两种方式差距不大了。

synchronized的缺陷

synchronized修饰的代码块,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,如果没有释放则需要无限的等待下去。

获取锁的线程释放锁只会有两种情况:

1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有。

2)线程执行发生异常,此时JVM会让线程自动释放锁。

总结一下,也就是说Lock提供了比synchronized更多的功能。但是要注意以下几点:

1)Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问。

2)synchronized不需要手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用。而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

Lock

Lock接口位于java.util.concurrent.locks包中。

 1 public interface Lock {
 2     // 用来获取锁。如果锁已被其他线程获取,则进行等待。
 3     void lock();
 4 
 5     // 用来获取锁。允许在等待时由其它线程调用interrupt方法来中断等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException。
 6     void lockInterruptibly() throws InterruptedException;
 7 
 8     // 用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false。
 9     boolean tryLock();
10 
11     // 用来尝试获取锁,如果拿到锁或者在等待期间内拿到了锁,则返回true。如果在某段时间之内获取失败,就返回false。
12     boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
13 
14     // 释放锁。
15     void unlock();
16 
17     // 获取Condition对象。
18     Condition newCondition();
19 }

lock方法

首先lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

由于在前面讲到如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

通常使用Lock来进行同步的话,是以下面这种形式去使用的:

1 Lock lock = ... ;
2 lock.lock();
3 try {
4     // 处理任务
5 } catch(Exception e) {
6 
7 } finally {
8     lock.unlock();// 释放锁
9 }

tryLock方法

tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。

tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。

一般情况下通过tryLock来获取锁时是这样使用的:

 1 Lock lock = ... ;
 2 if (lock.tryLock()) {
 3     try {
 4         // 处理任务
 5     } catch (Exception e) {
 6 
 7     } finally {
 8         lock.unlock();// 释放锁
 9     }
10 } else {
11     // 获取失败处理其他事情
12 }

lockInterruptibly方法

lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。

由于lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外声明抛出InterruptedException。

一般的使用形式如下:

1 public void method() throws InterruptedException {
2     Lock lock = ... ;
3     lock.lockInterruptibly();
4     try {
5         // 处理任务
6     } finally {
7         lock.unlock();
8     }
9 }

注意,当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为本身在前面的文章中讲过单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。

因此当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,只有进行等待的情况下,是可以响应中断的。

而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。

ReentrantLock

ReentrantLock类实现了Lock接口,并且ReentrantLock提供了更多的方法。

 1 public class Demo {
 2     public static void main(String[] args) {
 3         DemoThread dt = new DemoThread();
 4         Thread t1 = new Thread(dt, "窗口1");
 5         Thread t2 = new Thread(dt, "窗口2");
 6         t1.start();
 7         t2.start();
 8     }
 9 }
10 
11 class DemoThread implements Runnable {
12     private int ticket = 3;
13     Lock lock = new ReentrantLock();
14 
15     @Override
16     public void run() {
17         while (ticket > 0) {
18             try {
19                 Thread.sleep(1);
20             } catch (InterruptedException e) {
21                 e.printStackTrace();
22             }
23             
24             lock.lock();
25             try {
26                 if (ticket > 0) {
27                     System.out.println(Thread.currentThread().getName() + " 进入卖票环节 ");
28                     System.out.println(Thread.currentThread().getName() + " 售卖的车票编号为: " + ticket--);
29                 }
30             } catch (Exception e) {
31                 e.printStackTrace();
32             } finally {
33                 lock.unlock();
34             }
35         }
36     }
37 }

注意在声明Lock的时候,要注意不要声明为局部变量。

ReadWriteLock

ReadWriteLock也是一个接口,用来定义读写锁。

1 public interface ReadWriteLock {
2     Lock readLock();
3 
4     Lock writeLock();
5 }

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成两个锁来分配给线程,从而使得多个线程可以同时进行读操作。

ReentrantReadWriteLock

ReentrantReadWriteLock实现了ReadWriteLock接口,支持多个线程同时进行读操作。

 1 public class Demo {
 2     public static void main(String[] args) {
 3         DemoThread dt = new DemoThread();
 4         new Thread(() -> dt.showTicket(), "窗口1").start();
 5         new Thread(() -> dt.showTicket(), "窗口2").start();
 6         new Thread(() -> dt.showTicket(), "窗口3").start();
 7         new Thread(() -> dt.saleTicket(), "窗口4").start();
 8     }
 9 }
10 
11 class DemoThread {
12     private int ticket = 3;
13     ReadWriteLock lock = new ReentrantReadWriteLock();
14 
15     public void showTicket() {
16         while (ticket > 0) {
17             try {
18                 Thread.sleep(1);
19             } catch (InterruptedException e) {
20                 e.printStackTrace();
21             }
22             lock.readLock().lock();
23             try {
24                 if (ticket > 0) {
25                     System.out.println(Thread.currentThread().getName() + " 进入预售环节");
26                     System.out.println(Thread.currentThread().getName() + " 预售的车票编号为: " + ticket);
27                 }
28             } catch (Exception e) {
29                 e.printStackTrace();
30             } finally {
31                 lock.readLock().unlock();
32             }
33         }
34         System.out.println(Thread.currentThread().getName() + " 进入结束环节");
35     }
36 
37     public void saleTicket() {
38         while (ticket > 0) {
39             try {
40                 Thread.sleep(1);
41             } catch (InterruptedException e) {
42                 e.printStackTrace();
43             }
44             lock.writeLock().lock();
45             try {
46                 if (ticket > 0) {
47                     System.out.println(Thread.currentThread().getName() + " 进入售票环节");
48                     System.out.println(Thread.currentThread().getName() + " 售卖的车票编号为: " + ticket--);
49                 }
50             } catch (Exception e) {
51                 e.printStackTrace();
52             } finally {
53                 lock.writeLock().unlock();
54             }
55         }
56         System.out.println(Thread.currentThread().getName() + " 进入结束环节");
57     }
58 }

运行结果如下:

 1 窗口2 进入预售环节
 2 窗口1 进入预售环节
 3 窗口1 预售的车票编号为: 3
 4 窗口2 预售的车票编号为: 3
 5 窗口3 进入预售环节
 6 窗口3 预售的车票编号为: 3
 7 窗口4 进入售票环节
 8 窗口4 售卖的车票编号为: 3
 9 窗口4 进入售票环节
10 窗口4 售卖的车票编号为: 2
11 窗口2 进入预售环节
12 窗口3 进入预售环节
13 窗口1 进入预售环节
14 窗口1 预售的车票编号为: 1
15 窗口2 预售的车票编号为: 1
16 窗口3 预售的车票编号为: 1
17 窗口4 进入售票环节
18 窗口4 售卖的车票编号为: 1
19 窗口4 进入结束环节
20 窗口3 进入结束环节
21 窗口2 进入结束环节
22 窗口1 进入结束环节

从运行的结果来看,最多有三个线程在同时读,提高了读操作的效率。

如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。

如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

关于synchronized和Lock的比较

1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现。

2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生。而Lock在发生异常时,如果没有主动释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。

3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

5)Lock可以提高多个线程进行读操作的效率。

6)synchronized的底层是一个基于CAS操作的等待队列,synchronized还实现了自旋锁,并针对不同的系统和硬件体系进行了优化,而Lock则完全依靠系统阻塞挂起等待线程。

7)在资源竞争不是很激烈的情况下,synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态

锁的分类

在读很多并发文章中,会提及各种各样锁如公平锁,乐观锁等等,这篇文章介绍各种锁的分类。介绍的内容如下:

1 可重入锁
2 独享锁/共享锁
3 互斥锁/读写锁
4 公平锁/非公平锁
5 乐观锁/悲观锁
6 分段锁
7 偏向锁/轻量级锁/重量级锁
8 自旋锁

上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。

可重入锁

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

对于synchronized和ReentrantLock而言,都是可重入锁。

可重入锁的一个好处是可一定程度避免死锁,如果不是可重入锁的话,可能造成死锁。

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。

对于synchronized和ReentrantLock而言,都是独享锁。

但是对于ReadWriteLock而言,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁在Java中的具体实现就是ReentrantLock。读写锁在Java中的具体实现就是ReadWriteLock。

公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序。

对于synchronized而言,是一种非公平锁。

对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题

乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的

悲观锁在Java中的使用,就是利用各种锁。

乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在JDK5通过引入锁升级的机制来实现高效Synchronized。

这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

了解AQS

什么是AQS

AQS是英文单词AbstractQueuedSynchronizer的缩写,翻译过来就是抽象的队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock、Semaphore、CountDownLatch等等。

实现方式

AQS的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。

原理

AQS维护了一个state用来代表资源共享状态 private volatile int state; ,AQS提供了三种操作state的方法: int getState(); 、 void setState(int newState); 、 boolean compareAndSetState(int expect, int update); 。

AQS通过内置的FIFO同步队列 static final class Node 来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。

资源共享方式

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

使用分析

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。

tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。

tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

原文地址:https://www.cnblogs.com/shamao/p/11020704.html