目录
·前言
一、Callable 接口
1.Callable 介绍
2.代码示例
3.创建线程的方式
二、ReentrantLock 类
1.ReentrantLock 介绍
2.代码示例
3.与 synchronized 的区别
三、信号量 Semaphore 类
1.Semaphore 介绍
2.代码示例
3.保证线程安全的方式
四、CountDownLatch 类
1.CountDownLatch 介绍
2.代码示例
·结尾
·前言
在我们前面的文章中介绍的线程池与阻塞队列,其中涉及到的 BlockingQueue 、ThreadPoolExecutor 、ThreadFactory 、TimeUnit 、RejectedExecutionHandler……这些内容都来自于 JUC 中,JUC 全称是 java.util.concurrent 在这里面放了很多进行多线程编程时需要用到的类,在本篇文章中,我会介绍在 JUC 中一些前面文章没有介绍到,然后还比较常见的类,它们的作用以及用法。
一、Callable 接口
1.Callable 介绍
使用 Callable 接口也可以创建一个线程,它和 Runnable 接口类似,但是 Runnable 在执行任务的时候关注的是执行过程,并不关注结果,所以 Runnable 提供的 run 方法返回值类型是 void,而 Callable 接口是要关注执行结果的,所以 Callable 提供的 call 方法,返回值类型就是当前线程执行任务得到的结果。
2.代码示例
下面我用实现 Callable 接口与实现 Runnable 接口两种方式完成下面代码示例:用一个新的线程实现从 1+2+3+……+1000。先用实现 Runnable 创建线程的方法,代码及运行结果如下:
// 实现 Runnable 接口创建新线程计算 1+2+3+...+1000 的值
public class ThreadDemo16 {
// sum 用来接收 Runnable 中 run 方法执行的结果
public static int sum = 0;
public static void main(String[] args) throws InterruptedException {
// 创建线程,以实现 Runnable 的方法
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
// result 用来接收计算 1+2+3+...+1000 的值
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
// 将计算结果赋给 sum
sum = result;
}
});
// 启动 t1 线程
t1.start();
// 等待 t1 线程执行完毕
t1.join();
// 打印最终结果
System.out.println("1+2+3+....+1000 = " + sum);
}
}
通过上述结果可以看出这里的代码是没有问题的,但是上述代码的写法并不是特别的优雅,这是因为上述代码需要定义一个成员变量来接收 Runnable 中 run 方法执行的结果,试想一下,如果代码中有很多这种 run 方法需要返回一个结果,那么就要定义很多的成员变量,这会使我们代码的整体可读性降低,为了让上述代码看起来更优雅,下面我来使用实现 Callable 接口来创建一个新线程执行 1+2+3+……+1000 ,代码及运行结果如下:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
// 利用 Callable 接口创建线程完成 1+2+3+...+1000 的任务
public class CallableDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 实现 Callable 接口,这里需要指定一个泛型参数
// 这里的泛型参数就是你要执行任务的返回值类型
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
// result 用来接收并返回 1+2+3+...+1000 的值
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
// 返回得到的结果
return result;
}
};
// 由于 Thread 中没有提供构造函数来传入 Callable,所以
// 引入 FutureTask 作为 Callable 与 Thread 的粘合剂
FutureTask<Integer> futureTask = new FutureTask<>(callable);
// 创建线程 t 把 futureTask 传入
Thread t = new Thread(futureTask);
// 启动线程 t
t.start();
// 打印结果,这里的 futureTask 调用的 get 方法是带有阻塞功能的
// 如果线程没有执行完毕, get 就会阻塞,等线程执行完了,return 结果
// 后,就会被 get 返回回来
System.out.println("1+2+3+...+1000 = " + futureTask.get());
}
}
如上图所示的运行结果可以看出我们这里的代码编写是没有问题的,使用实现 Callable 接口这种方法不需要引用额外的成员变量也做到将执行任务的结果获取到,这是利用 futureTask 作为一个凭据,用它来等任务执行完后去取结果(接收 call 方法的返回值)。
其实使用 Callable 起到的一种“锦上添花”的效果,因为 Callable 能干的事情,使用 Runnable 也可以做到,只不过对于上述需要带有返回值的任务,使用 Callable 会更好一些,因为这样写出来的代码更直观,更简单,只不过我们在使用 Callable 的过程中不要忘记 FutureTask 起到的作用(在代码注释中)。
3.创建线程的方式
关于 Callable 接口的介绍也就这些,下面我再来把创建线程的几种方式给大家整理一遍,如下所示:
- 继承 Thread 类(包含使用匿名内部类的方式);
- 实现 Runnable 接口(包含使用匿名内部类的方式);
- 实现 Callable 接口;
- 基于 lambda 表达式;
- 基于线程池。
以上这几种创建线程的方式在前面前面的文章中都有进行介绍,加上本篇文章介绍的实现 Callable 接口创建线程的方式,我一共介绍了五种,希望大家要理解这里的内容。
二、ReentrantLock 类
1.ReentrantLock 介绍
如下图所示:
ReentrantLock 的中文意思就是可重入锁,它延续了操作系统中传统锁的风格,使用这个类实例出的对象有两个方法是 lock(加锁) 与 unlock(解锁),这种写法有一个问题就是容易引起加了锁之后忘记解锁,比如在 unlock 之前触发了 return 或者异常,就可能引起 unlock 执行不到了,所以正确使用 ReentrantLock 就需要把 unlock 操作放入 finally 中,让这个操作无论如何都会被执行到,只不过每次加锁都要套一层 try --- finally,比较繁琐,所以实际涉及到加操作时,使用 synchronized 的会更多一些。
2.代码示例
介绍完 ReentrantLock 的基础加锁方式,下面我就使用 ReentrantLock 来写一个代码案例,这个代码案例就是创建两个线程,在两个线程中分别对变量 count 进行自增 5w 次的操作,要求保证线程安全,那么代码和运行结果如下所示:
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建 ReentrantLock 对象,用来进行加锁操作
ReentrantLock reentrantLock = new ReentrantLock();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
// 防止对 count 自增过程有其他线程对其进行修改,进行加锁
// 使用 try---finally 套住,确保 unlock 方法被执行到
try {
reentrantLock.lock();
count++;
}finally {
reentrantLock.unlock();
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
// 防止对 count 自增过程有其他线程对其进行修改,进行加锁
// 两个线程都要对 count++ 加锁,不然不产生锁冲突还是无法保证线程安全
try {
reentrantLock.lock();
count++;
}finally {
reentrantLock.unlock();
}
}
});
// 启动 t1 和 t2 线程
t1.start();
t2.start();
// 等待 t1 和 t2 线程执行完毕
t1.join();
t2.join();
// 打印最终结果
System.out.println("count = " + count);
}
}
如运行结果所示,用 ReentrantLock 也可以保证线程安全,以后在使用 ReentrantLock 进行加锁解锁的操作就可以仿照上述代码来进行使用了。
3.与 synchronized 的区别
ReentrantLock 是可重入锁,synchronized 也是可重入锁,那么既然有了 synchronized 为什么还要留有 ReentrantLock 呢?这就要谈到他们的区别了,有以下几点:
- ReentrantLock 提供了 tryLock 操作,使用 lock 直接进行加锁,如果加锁失败会进入阻塞等待,而使用 tryLock 尝试进行加锁,如果加锁失败会返回 false,不会产生阻塞,此时我们就可以有更多的操作空间,比如放弃加锁,或者先进行其他工作之后再来进行加锁,而 synchronized 没有这样的方法,使用 synchronized 进行加锁如果加锁失败就只能阻塞等待;
- ReentrantLock 提供了公平锁的实现,如下图 ReentrantLock 的构造方法所示:我们在使用构造方法创建 ReentrantLock 对象时可以在构造方法中指定一个参数,来表示当前创建的锁是公平锁还是非公平锁,而 synchronized 只能是非公平锁;
- ReentrantLock 与 synchronized 搭配的等待通知机制不同,对于synchronized ,搭配的是 wait / notify,此时唤醒操作是随机唤醒,对于 ReentrantLock ,搭配的是 Condition 类,功能要比 wait / notify 略强一点,这里可以指定唤醒的线程。
三、信号量 Semaphore 类
1.Semaphore 介绍
信号量,是用来表示“可用资源的个数”,本质上可以看作是一个计数器,为了更直观的理解这里信号量的含义,我来举一个生活中的例子:
信号量可以看作是停车场门口的电子牌,上面显示当前剩余的车位,当有车开进停车场,电子牌上的数字就会 -1,开出来一个车,电子牌上的数字就会 +1 此时如果电子牌上的数字为 0 就无法再向停车场中停车,这时就要阻塞等待,直到有车从停车场出来了。
在上述例子中,可以直观看出信号量表示的就是“可用资源的个数”,在这里申请一个可用资源,就会使数字 -1 这个操作也称为 P 操作,释放一个可用资源,就会使数字 +1 这个操作也称为 V 操作,如果数值为 0 再进行 P 操作,P 操作就会阻塞。
对于信号量来说,这也是操作系统内部给我们提供的一个机制,操作系统对应的 API 被 JVM 封装了起来形成 Semaphore 类,所以我们就可以使用 Java 代码来调用这里的相关操作了。
2.代码示例
下面我来通过一个简单的代码来演示一下 Semaphore 的基本用法,代码及运行结果如下所示:
import java.util.concurrent.Semaphore;
public class SemaphoreDemo1 {
public static void main(String[] args) throws InterruptedException {
// 创建一个信号量对象,设置初始信号量的值为 1
// 表示当前可以资源个数为 1
Semaphore semaphore = new Semaphore(1);
// acquire 方法就表示 P 操作
semaphore.acquire();
System.out.println("执行 P 操作");
// release 方法就代表 V 操作
semaphore.release();
System.out.println("执行 V 操作");
semaphore.acquire();
System.out.println("执行 P 操作");
semaphore.acquire();
System.out.println("执行 P 操作");
semaphore.acquire();
System.out.println("执行 P 操作");
}
}
从运行结果可以看出,由于信号量可用资源只有一个,所以在进行多次 P 操作时,第二个 P 操作就会阻塞,等待执行 V 操作来释放一个可用资源再继续运行,这种执行逻辑不知道有没有让大家想起锁,其实所谓的锁本质上也是一种特殊的信号量,锁可以认为是可用资源数为 1 的信号量,释放锁的状态信号量值就为 1,加锁状态的信号量值就为 0,对于这种非 0 即 1 的信号量就可以称为是“二元信号量”。
下面我来使用信号量作为锁来完成两个线程对变量 count 分别进行 5w 次自增操作的代码示例,并保证代码的线程安全,代码及运行结果如下所示:
import java.util.concurrent.Semaphore;
public class SemaphoreDemo2 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建信号量对象,设置可用资源数为 1
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
try {
// 申请资源,如果当前资源数为 0 就阻塞等待
// 否则就进行资源申请的操作,达到加锁的效果
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
// 释放当前申请的资源,达到解锁的效果
semaphore.release();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
try {
// 申请资源,如果当前资源数为 0 就阻塞等待
// 否则就进行资源申请的操作,达到加锁的效果
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
// 释放当前申请的资源,达到解锁的效果
semaphore.release();
}
});
// 启动 t1 与 t2 线程
t1.start();
t2.start();
// 等待 t1 与 t2 线程执行完毕
t1.join();
t2.join();
// 打印结果
System.out.println("count = " + count);
}
}
从运行结果,可以看出使用 Semaphore 也能达到加锁的效果来保证线程安全。
3.保证线程安全的方式
介绍完 Semaphore 保证线程安全的方式就又多了一种,下面我来对前面文章与本篇文章中介绍到保证线程安全的方式做一个汇总,一共有以下几种方式:
- 使用 synchronized 进行加锁;
- 使用 ReentrantLock 进行加锁;
- 使用 CAS 机制(原子类);
- 使用 Semaphore 二元信号量来加锁。
四、CountDownLatch 类
1.CountDownLatch 介绍
CountDownLatch 是一个针对特定场景解决问题的小工具,比如,多线程执行一个任务,把大的任务拆成几个部分,由每个线程分别执行,最典型的场景我感觉应该是“多线程下载”,这里的机制就是把一个大的文件拆成多个部分,每个线程负责下载一部分,下载完成后最终把下载结果拼接到一起,像“多线程下载”这样的场景,我们需要等最终执行完成后把所有内容拼接到一起,这个拼接操作一定是要等所有线程都执行完成,使用 CountDownLatch 这个类就可以方便的感知这个每个线程执行的情况,会比调用很多次 join 方法简单方便一些。
2.代码示例
下面我来写一个简单的代码来模拟一下“多线程下载”的场景,代码及运行结果如下所示:
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
// 1. 此处构造方法中写 10 代表有 10 个线程/任务
CountDownLatch latch = new CountDownLatch(10);
// 创建出 10 个线程负责下载
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(()->{
// 引入随机数模拟每个线程下载速度不一致
Random random = new Random();
int time = (random.nextInt(5) + 1) * 1000;
System.out.println("线程 " + id + " 开始下载");
try {
// 这里休眠模拟当前线程正在下载
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程 " + id + " 下载完成");
// 2. 调用 countDown 方法告知 CountDownLatch 我下载完成了
latch.countDown();
});
// 启动线程
t.start();
}
// 3. 通过 await 方法来等待所有任务执行结束,也就是 countDown 方法被调用 10 次了
latch.await();
// 4. 此时任务就算已经下载完成了
System.out.println("所有任务都下载完成");
}
}
CountDownLatch 可以用来协调多个线程的执行,而不必等待单个线程完成。上述代码如果使用 join 的方式,那就只能使用每个线程执行一个任务。
·结尾
文章到此也就要结束了,本篇文章补充介绍了 JUC 中一些常见的类,这里就介绍了四种,其中使用实现 Callable 接口的方式又为我们创建线程增添一种方式,使用 ReentrantLock 和 Semaphore 可以进行加锁操作为我们保证线程安全增添了两种不一样的思路,使用 CountDownLatch 这样的工具类也可以让我们在特殊场景下写出更便捷的代码,如果本篇文章对你有所帮助,希望能收到你的三连支持,如果对文章介绍的内容有所疑问,欢迎在评论区进行留言,那么我们下一篇文章再见咯~~~