同步控制是并发程序必不可少的重要手段,本文我们将通过重入锁、读写锁、信号量、倒计数器和循环栅栏以及他们的实例来介绍Java并发程序中的同步控制。
JMM
JMM(Java Memory Model)是一种基于计算机内存模型的机制与规范。保证了共享内存的原子性、可见性、有序性
JMM原理图
线程在访问主内存中的变量时并不是直接对主存中的变量进行读写,而是对主内存中的变量复制一份变量副本存入当前线程的工作内存中,然后对线程工作内存中的数据进行一系列的操作,操作完成后再将线程工作内存中的变量副本更新到主存中,而且不同线程的工作内存是私有的,其他线程不能对其进行读写操作。
因为线程工作内存的存在,当不同线程对**同一变量(共享变量)**进行操作时,就会出现线程安全问题。
接下来我们针对线程对共享数据的访问过程来分析线程安全问题出现的原因。
线程对共享数据的访问及线程安全
我们假设有一个线程的工作就是对变量x
进行自增,同时主存中x
的初始值为0。
x=0
,线程启动后先将主存中的变量x
复制一份副本存放在线程工作内存中,然后对线程工作内存中的x
进行自增,此时线程工作内存中的变量为x=1
,然后将此变量副本在刷新到主存中,此时主存中的数据便为x=1
,符合我们预期的结果。x=0
进行自增,这样我们预期的结果应该是主存中x=2
,我们假设两个线程的优先级相同,首先线程A首先获得了CPU资源,并将主存中的x=0
拷贝到线程工作内存中,然后完成了自增工作,我们假设这时线程A进入了阻塞状态或者CPU的时间片到了导致线程A失去了CPU资源而无法继续运行,此时线程A的线程工作内存中x=1
,但是因为还没及时的将变量副本刷新到主存中该线程就失去了CPU资源,导致主存中的x的值仍为0;然后我们的线程B得到了CPU资源,并顺利的完成了所有工作并及时的将变量副本刷新到了主存中,那么此时主存中的数据为x=1
,此时线程B已经运行结束,线程A重新获得了CPU资源,此时线程A的工作内存中x=1
刷新到了主存中,则主存中的数据更新为x=1
,此时两个线程都运行结束,本来我们预期的结果应为x=2
,而实际上主存中x=1
,所以我们说这个线程是不安全的。我们针对线程安全问题最基本的解决方案就是加锁(同步监视器),在我们的生活中凡是针对概率事件总会有人持有乐观状态和悲观状态,同样在线程安全问题中也存在乐观锁和悲观锁,悲观锁就是我们持有一个悲观的态度,我们悲观的认为每次都会出现线程安全问题,那么我们就对共享数据进行加锁,让共享资源变为某个线程的独占资源。而乐观锁就是我们认为不会出现线程安全问题,通常使用CAS算法实现。
我们本文着重介绍悲观锁。
我们首先介绍synchronized
关键字的增强版-重入锁(ReentrantLock
)
重入锁与synchronized
关键字的功能相同,但是更加灵活,它不会自动释放同步监视器,而是一种显示锁,需要手动加锁和释放,因此我们只需要在进行共享数据的操作的地方进行加锁,极大的增加了程序的灵活性。
接下来我们通过一个著名的售票的例子来解释重入锁的使用方法,首先我们要实例化一个java.util.concurrent.locks.ReentrantLock
的对象,然后再访问共享数据之前用lock()
方法进行加锁,然后操作结束后用unlock()
方法释放锁,为了保证线程必须释放同步监视器,我们通常搭配try-finally
代码块搭配使用,在try-finally
代码块之前进行加锁,在finally
中释放锁。
注:与synchronized
关键字相同,多个线程访问同一个共享资源时应该加同一把锁
方法介绍:
lock()
:获得锁,如果锁已经被占用则等待lockInterruptibly()
:获得锁,但优先响应中断tryLock()
:尝试获得新锁,成功返回true
,不成功返回false
,不等待直接返回tryLock(long time, TimeUnit unit)
:在给定时间内获得锁unlock()
:释放锁代码示例:
package pres.zxz.thread.safe;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author Lansion
* @program JUCDemo
* @description 同步锁解决线程的安全问题
* @date 2020/4/19 16:28
* @ClassName LockDemo
*
**/
public class LockDemo {
public static void main(String[] args) {
WindowLock wl = new WindowLock();
var thread = new Thread(wl, "窗口1");
var thread2 = new Thread(wl, "窗口2");
var thread3 = new Thread(wl, "窗口3");
thread.start();
thread2.start();
thread3.start();
}
}
class WindowLock implements Runnable {
private int ticket = 10000;
// 实例化ReentrantLock对象
private Lock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
// 加锁
lock.lock();
try {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "售票,票号为:" + ticket);
ticket--;
} else {
break;
}
} finally {
// 释放锁
lock.unlock();
}
}
}
}
重入锁除了在使用灵活外还提供了一些高级的功能
中断响应
使用重入锁可以在线程等待的过程中根据需求取消对锁的请求。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无需等待可以停止工作。在很大程度上处理了死锁问题带来的麻烦。
下面我们通过代码来解释:
package pres.zxz.thread.safe;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author Lansion
* @program JUCDemo
* @description 重入锁的中断响应
* @date 2020/4/29 1:44
* @ClassName IntLock
**/
public class IntLock implements Runnable {
private static ReentrantLock lock1 = new ReentrantLock();
private static ReentrantLock lock2 = new ReentrantLock();
private int lock;
/**
*
* 我们根据lock的数值来决定加锁顺序,以便构成死锁
*
* @param lock
*/
public IntLock(int lock) {
this.lock = lock;
}
@Override
public void run() {
try {
if (lock == 1) {
lock1.lockInterruptibly();
Thread.sleep(500);
lock2.lockInterruptibly();
System.out.println(Thread.currentThread().getId() + ":线程完成");
} else {
lock2.lockInterruptibly();
Thread.sleep(500);
lock1.lockInterruptibly();
System.out.println(Thread.currentThread().getId() + ":线程完成");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1.isHeldByCurrentThread()) {
lock1.unlock();
}
if (lock2.isHeldByCurrentThread()) {
lock2.unlock();
}
System.out.println(Thread.currentThread().getId() + ":线程退出!");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new IntLock(1));
Thread t2 = new Thread(new IntLock(2));
t1.start();
t2.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.interrupt();
}
}
我们通过构造函数来控制加锁顺序,构造t1
、t2
两个线程,t1
先获得lock1
再获得lock2
,t2
先获得lock2
再获得lock1
,我们通过两个休眠来使得t1
线程先后获得第一把锁后让t2
获得CPU资源,这样t1
手持lock1
等待lock2
,而t2
获得lock2
等待lock1
,这样一个经典的死锁程序。
而在这里我们使用重入锁的lockInterruptibly()
方法获得锁,这样可以优先响应中断,因此在程序的65行,我们中断了t2
,这样t2
放弃了对lock1
的申请,同时释放lock2
,使得t1
能顺利执行。
运行结果:
"C:\Program Files\Java\jdk-1.8.0\bin\java.exe" "-javaagent:C:\software\IntelliJ IDEA 2019.3.3\lib\idea_rt.jar=24192:C:\software\IntelliJ IDEA 2019.3.3\bin" -Dfile.encoding=UTF-8 -classpath C:\WorkSpace\JavaWeb\JUCDemo\out\production\threadlearn pres.zxz.thread.safe.IntLock
java.lang.InterruptedException
at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:944)
at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1263)
at java.base/java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:317)
at pres.zxz.thread.safe.IntLock.run(IntLock.java:35)
at java.base/java.lang.Thread.run(Thread.java:834)
15:线程退出!
14:线程完成
14:线程退出!
Process finished with exit code 0
锁申请等待限时
使用tryLock(long time, TimeUnit unit)
方法可以实现锁的申请等待限时,方法的第一个参数是等待时长,第二个参数时时长单位,这个方法与上一个方法一样可以避免死锁的问题,线程不会一直等待资源,而是不断尝试,只要程序运行时间足够长,线程总会得到所有的资源从而顺利运行。
公平锁
public ReentrantLock(boolean fair)
当参数fair
为true
时,表示锁是公平的,即线程交替获得锁,公平锁的一大优点就是不会产生饥饿现象,但是要实现公平锁,就需要系统维护一个有序队列,因此成本比较高,性能低下。
而一般默认情况下,一个线程会倾向于再次获取已持有的锁,这种分配方式是高效的,但是是不公平的。
就重入锁的实现来看,主要包含三个元素:
park()
和unpark()
。用来挂起和恢复线程。没有得到锁的线程会被挂起。我们在对共享数据进行加锁时,我们考虑一个问题,多个线程去读一个资源的操作需不需要互斥,仿佛我们读取数据并不影响其他线程也去读取,我们在研究线程安全问题产生的原因时可以发现,只有写入数据时才会出现安全问题。那既然这样如果我们对一个读多余写的程序的读和写使用相同的加锁策略会不会严重的影响了程序的性能。
在JDK1.5
中提供了一个类ReadWriteLock
,用锁分离的机制来提高性能。
读写锁允许多个线程同时读,但是当有写操作时就会阻塞。
具体约束情况我们做一下总结
读 | 写 | |
---|---|---|
读 | 非阻塞 | 阻塞 |
写 | 阻塞 | 阻塞 |
读写锁的操作很简单,和重入锁差不多,我们直接放代码自己体会
package pres.zxz.juc;
import java.util.Random;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
*
* @author Lansion
* @program JUCDemo
* @description 读写锁
* @date 2020/4/20 20:34
* @ClassName ReadWriteLockTest
**/
public class ReadWriteLockTest {
public static void main(String[] args) {
ReadWriteLockDemo rw = new ReadWriteLockDemo();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
rw.write(new Random().nextInt());
}, "Write").start();
}
for (int i = 0; i < 10; i++) {
new Thread(rw::read).start();
}
}
}
class ReadWriteLockDemo {
private int number = 0;
private ReadWriteLock lock = new ReentrantReadWriteLock();
/**
* 读取数据
*/
public void read() {
// 获取读锁
lock.readLock().lock();
try {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "读取数据 : " + number);
} finally {
// 释放读锁
lock.readLock().unlock();
}
}
/**
* 写入数据
* @param number 要写入的数据
*/
public void write(int number) {
// 获取写锁
lock.writeLock().lock();
try {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "写入数据 : " + number);
this.number = number;
} finally {
// 释放写锁
lock.writeLock().unlock();
}
}
}
在读多写少的情况下我们可以发现读写锁的优势,我们也可以写一个使用重入锁的读写程序,与这个流程一致,来比较二者的性能。
java.util.concurrent.CountDownLatch
是一个非常实用的多线程控制工具类。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束在开始执行。我们通过一个案例来看这个工具类的作用和使用:
拿我们现在的网课为例,可能有个别的老师喜欢上课点名,对所有同学点完名后再对出勤情况进行汇总。这样我们用一个程序进行模拟点名汇总的过程的话,我们就可以用多个线程模拟点名签到的过程,然后在主线程中对结果进行汇总,这样我们看汇总应该在签到之后,所有签到的线程都运行结束后,主线程才可以继续进行汇总操作,这样我们就可以用CountDownLatch
实现,我们看具体的代码:
package pres.zxz.juc.control;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static java.util.concurrent.Executors.*;
/**
* @author Lansion
* @version 1.0
* @program JUCDemo
* @date 2020/4/22 17:01
* @ClassName CountDownLatch
* @since 1.0
* @description 到计数器--CountDownLatch实例:点名
**/
public class CountDownLatchTest {
/**
*
* 结果汇总
*
* @param list 签到线程运行结束后的结果
* @return 汇总结果
*/
private static Map<String, Integer> summarizing(List<Future<Student>> list) {
Map<String, Integer> map = new HashMap<String, Integer>(2);
map.put("已到", 0);
map.put("未到", 0);
list.forEach(future -> {
try {
Student s = future.get();
if (s.getFlag()) {
map.put("已到", map.get("已到")+1);
} else {
map.put("未到", map.get("未到")+1);
}
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
return map;
}
public static void main(String[] args) {
final int studentNum = 100;
final CountDownLatch latch = new CountDownLatch(studentNum);
ExecutorService pool = newCachedThreadPool();
CallTheRoll ctr = new CallTheRoll(latch);
List<Future<Student>> studentList = new ArrayList<Future<Student>>();
for (int i = 0; i < studentNum; i++) {
studentList.add(pool.submit(ctr));
}
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("点名结束!");
Map<String, Integer> map = summarizing(studentList);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + " : " + entry.getValue());
}
pool.shutdown();
}
}
/**
*
* 模拟签到线程
*
*/
class CallTheRoll implements Callable<Student> {
AtomicInteger sum = new AtomicInteger(0);
CountDownLatch latch;
public CallTheRoll(CountDownLatch latch) {
this.latch = latch;
}
/**
*
* 模拟签到
*
* @return 签到学生状况
* @throws Exception
*/
@Override
public Student call() throws Exception {
Student student;
try {
System.out.println("正在点名。。。。。。。。。");
student = new Student(sum.incrementAndGet(), new Random().nextBoolean());
} finally {
latch.countDown();
}
return student;
}
}
/**
*
* 学生类
* flag:确定学生是否签到成功
*
*/
class Student {
private int id;
private Boolean flag;
public Student(int id, Boolean flag) {
this.id = id;
this.flag = flag;
}
public int getId() {
return id;
}
public Boolean getFlag() {
return flag;
}
}
假设我们有100名学生,在程序的第52行,我们构造了一个参数为100的CountDownLatch
,然后在模拟点名的线程中,也就是109行,调用CountDownLatch.countDown()
使计数器减一,在主线程中在线程池中加载完所有线程后调用``CountDownLatch.await()`(63行)方法使主线程等待,等待计数器为0后主线程再继续运行,也就是100个点名线程全部运行结束后主线程再继续运行调用方法进行汇总。
java.util.concurrent.CyclicBarrier
也是一种多线程并发控制工具。与CountDownLatch
类似,但比CountDownLatch
更为强大。Cyclic意为循环,也就是说计数器可以循环使用,当计数器归零后可以从头在进行一次计数。我们接着用一个实例来看它的作用:
假设我们在上课期间老师要抽出一部分同学来分工合作完成一项内容,首先我们就要先点一些同学的名然后进行汇总,接着让这些同学分别完成各自的任务再进行一次汇总,与上一个例子类似,但不同的是进行了两轮操作,这样就用到了我们的循环栅栏,代码如下:
package pres.zxz.juc.control;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import static java.util.concurrent.Executors.*;
/**
* @author Lansion
* @version 1.0
* @program JUCDemo
* @description 循环栅栏
* @date 2020/4/27 9:20
* @ClassName CyclicBarrierTest
* @since 1.0
**/
public class CyclicBarrierTest {
public static void main(String[] args) {
final int N = 10;
boolean flag = true;
CyclicBarrier cyclic = new CyclicBarrier(N, new Statistics(N, flag));
ExecutorService pool = newFixedThreadPool(N);
for (int i = 0; i < N; i++) {
pool.submit(new CyclicStudent(i+"", cyclic));
}
pool.shutdown();
}
}
/**
*
* 模拟学生签到工作进程
* 计数器循环两轮
*
*/
class CyclicStudent implements Runnable {
private String id;
private final CyclicBarrier cyclic;
public CyclicStudent(String id, CyclicBarrier cyclic) {
this.id = id;
this.cyclic = cyclic;
}
@Override
public void run() {
try {
signIn();
cyclic.await();
doWork();
cyclic.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
private void signIn() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("学号:" + this.id + "到!");
}
private void doWork() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("学号:" + this.id + "完成工作!");
}
}
/**
*
* 计数器归零后的操作
* 因为循环两轮,所以用布尔变量区分每次后的操作
*
*/
class Statistics implements Runnable {
private final int N;
private boolean flag;
public Statistics(int n, boolean flag) {
N = n;
this.flag = flag;
}
@Override
public void run() {
if (flag) {
System.out.println("学生" + N + "个,全部到齐!");
flag = false;
} else {
System.out.println("学生" + N + "个,任务完成!");
}
}
}
首先我们看程序第23行,我们构建了一个CyclicBarrier
实例,并明确了计数器的个数和计数器归零后要完成的工作。接着我们看程序的第54行,调用了CyclicBarrier.await()
方法,该方法使当前线程进入等待状态,并同时使计数器减一,当计数器减到0后,执行我们的汇总操作,并同时释放所有等待的线程,当释放的线程运行到56行时,又进入了等待状态,这样就又进行了一轮操作。
java.util.concurrent.Semaphore
信号量为多线程协作提供了更为强大的控制方法。信号量是对锁的一种扩展。因为无论是synchronized
还是重入锁,都只是允许一次一个线程访问共享资源,而信号量可以指定多个线程同时访问共享资源.常用的方法:
public void acquire()
:尝试获得一个准入的许可。若无法获得则会等待,直到有一个线程释放许可,或者当前线程被中断。public void acquireUniterruptibly()
:与上一个功能类似但不响应中断public boolean tryAcquired()
:尝试获得许可,成功返回true
不成功返回false
,不会等待。public boolean tryAcquired(long timeout, TimeUnit unit)
:与上一个相似,但会等待一定的时间。public void rlease()
:释放许可。同样我们举一个例子:
在疫情结束后,我们返校用餐的时候,应该分批用餐,当我们使用程序模拟这个过程时,我们就可以使用信号量,规定多少个用餐线程可以同时使用餐厅这个共享资源。
package pres.zxz.juc;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import static java.util.concurrent.Executors.newFixedThreadPool;
/**
* @author Lansion
* @version 1.0
* @program JUCDemo
* @date 2020/4/21 21:16
* @ClassName SemaphoreTest
* @since 1.0
* @description 信号量--Semaphore
**/
public class SemaphoreTest {
public static void main(String[] args) {
final int sum = 20;
ExecutorService exec = newFixedThreadPool(sum);
final SemaphoreDemo demo = new SemaphoreDemo();
for (int i = 0; i < sum; i++) {
exec.submit(demo);
}
exec.shutdown();
}
}
class SemaphoreDemo implements Runnable {
final Semaphore semphore = new Semaphore(5);
@Override
public void run() {
try {
semphore.acquire();
Thread.sleep(2000);
System.out.println(Thread.currentThread().getId() + " : 就餐完成!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semphore.release();
}
}
}
程序的33行我们规定的信号量为5,也就是可以有五个线程同时访问共享资源,在第38行我们首先获得一个许可,饭后执行操作,操作结束后释放许可,同样因为无论出现什么情况我们都应该释放许可,避免它一直占用共享资源,所以我们应借助try-finally
代码块来实现许可的释放。