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。