🎇个人主页:Ice_Sugar_7
🎇所属专栏:JavaEE
🎇欢迎点赞收藏加关注哦!
🍉CAS
compare and swap 的缩写,它是一个特殊的 cpu 指令
,负责完成“比较和交换”的工作
下面是 CAS 内部运行的伪代码
address 是内存中的值;expectedValue 是寄存器 expected 中的值;swapValue 是另一个寄存器 swap 中的值
boolean CAS(address,expectedValue,swapValue) {
if(&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
CAS 中会比较 address 的值是否和 expected 寄存器中的值相同。若相同,就交换 swap 的值和 address 的值,并返回 true;若不同,就只返回 false
我们刚才说 CAS 是一条 cpu 指令,这就说明上面的操作是原子的,它为我们编写线程安全的代码提供了新思路
之前线程安全都是靠加锁保证的,而使用 CAS 不涉及加锁,也就不会阻塞,合理使用可以保证线程安全,而且效率更高
Java 的标准库对 CAS 进行进一步的封装,提供一些工具类让我们直接使用,原子类
是最主要的工具类之一
java.util.concurrent.atomic 里面所有类都是基于 CAS 实现的
拿 AtomicInteger 来说,对这个类的对象进行 ++(getAndIncrement 方法或 incrementAndGet 方法) 或 – 操作对变量的修改是一个 CAS 指令,而一个指令天然就是原子的,所以是线程安全的
getAndIncrement 的实现如下(在标准库源码的基础上进行了简化)
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value; //这个 oldValue 实际上是寄存器中存放的值,这一步是把它初始化为 AtomicInteger 实例里保存的 value
while(CAS(value,oldValue,oldValue+1) != true) { //如果比较交换成功,那循环就结束了,此时 value 更新为 value + 1
oldValue = value;
}
return oldValue;
}
}
在 while 循环的判定条件中,如果 value 和 oldValue 不一样,那意味着在 CAS 之前有另一个线程修改了 value,就会进入循环修改 oldValue 的值(重新读取新的 value 到 oldValue 中,此时的 value 是内存中最新的值)
之前涉及的线程不安全是因为内存中的值变了,但是寄存器的值没有变,所以接下来的修改就会出错。使用 CAS 这种方式可以识别内存的值是否改变,巧妙地解决了之前的线程安全问题
确保线程安全也是有代价的——自旋消耗了更多 cpu 资源
下面演示一下 AtomicInteger 在多线程中同时对一个变量进行自增操作:
public class TestDemo {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger();
Thread t1 = new Thread(()-> {
for(int i = 0;i < 50000;i++) count.getAndIncrement();//后置++
});
Thread t2 = new Thread(()-> {
for(int i = 0;i < 50000;i++) count.getAndIncrement();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
🍉Callable 接口
之前我们学习创建线程时,主要了解了继承 Thread 类、实现 Runnable 接口、使用 lambda 表达式这几种方法,但是它们没法获取到返回值,只能通过引入新的成员变量,在线程中修改这个变量来间接实现“获取返回值”
如果我们希望获取线程中的返回值时,可以用 Callable 接口
泛型参数 V 表示返回值类型
Callable 用起来还是有一点小麻烦的,因为 Thread 没有提供构造方法来传入 Callable,所以需要引入一个 Futuretask 类
作为“中间人”,把 Callable 和 Thread 连接起来
Futuretask 意为“未来的任务”,也就是待执行的任务,这里的任务在 Callable 的实例 callable里面,需要把 callable “投喂” 给 Futuretask
先创建一个 FutureTask 实例 futuretask,然后将 callable 放进去,再在创建线程对象的时候把 futuretask 放进去
举个例子,创建一个线程,求 1+2+3+…+1000
public class TestDemo7 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for(int i = 1;i <= 1000;i++)
sum += i;
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable); //FutureTask 的泛型参数和 Callable 的一致
Thread t= new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
我们通过 Futuretask 的 get 方法来获取返回值,这个操作带有阻塞功能,如果线程还没执行完毕,那么 get 就会阻塞,等到线程执行完,return 的结果就会被 get 返回回来
虽然 Callable 能做的任务,使用 Runnable 也可以做,不过对于有返回值的任务,还是用 Callable 的代码比较直观一点
🍉ReentrantLock
早期 Java 的 synchronized 功能不够强大,而且没有各种优化,于是用 ReentrantLock 实现可重入锁。synchronized 功能丰富起来之后就比较少用 ReentrantLock 了
不过还是有必要了解一下 ReentrantLock,下面来说说它和 synchronized 的不同之处
-
ReentrantLock 需要
手动加锁,解锁
传统的锁提供了 lock 和 unlock 两种方法分别用于加锁、解锁,不像 synchronized 那么自动化,所以传统的写法可能会出现加锁后忘记 unlock 的问题,或者由于触发了 return、异常导致执行不到 unlock
所以要用 ReentrantLock 就最好把 unlock 放到 finally 中(因为 finally 的语句是一定会执行的),不过这样就是麻烦了一些 -
ReentrantLock 提供了
tryLock
的操作
使用 lock 加锁,如果加锁没成功就会阻塞
而使用 tryLock,加锁不成功的话不会阻塞
,而是直接返回 false。因此通过 tryLock 可以提供更多的可操作空间 -
ReentrantLock 提供了
公平锁
的实现
只需在 ReentrantLock 的构造方法中填写参数就可以设置为公平锁(通过队列记录线程加锁的先后顺序) -
等待通知机制不一样
synchronized 是搭配 wait、notify 使用;ReentrantLock 是搭配Condition 类
,它的功能比 wait、notify 强一丢丢(不过在实际开发中 synchronized 已经够用了)
🍉信号量
以生活中的例子引入
一些停车场的门口会有一块电子牌,上面显示当前剩余多少个车位,每开进一辆车,上面的数字就会 -1,反之,车开出停车场就会 +1
这里的“剩余的车位数”就是信号量
,表示可用资源的个数
(在这个例子中就是车位)
申请一个可用资源,信号量就 -1,这个操作称为 P 操作
;释放一个可用资源,信号量就 +1,这个操作称为 V 操作
。如果数值为 0 了还继续 P 操作,那 P 操作就会阻塞
(没有车位了当然不能开进去了)
信号量也是操作系统内部提供的机制,jvm 对操作系统对应的 api 进行封装,我们可以通过 Java 的 Semaphore 类
来调用这些相关操作
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(2); //构造方法的参数表示信号量
semaphore.acquire(); //获取一个可用资源
System.out.println("P 操作");
semaphore.acquire();
System.out.println("P 操作");
semaphore.acquire(); //信号量已经为 0 了,再 acquire 就会阻塞
System.out.println("P 操作");
semaphore.release(); //释放一个可用资源
System.out.println("V 操作");
}
信号量其实和锁有联系,锁本质上也是一种特殊的信号量,我们可以认为它是计数值为 1 的信号量(同一时间只能有一个线程持有某个锁)。在释放状态下就是 1,处于加锁状态时就是 0,对于这种非 0 即 1 的信号量,我们称为二元信号量
🍉CountDownLatch
CountDownLatch 是一个同步工具类,它可以控制一个或多个线程等待其他线程完成操作,我们之前使用 join 一次只能等待一个线程,如果要等待多个线程,要写很多个 join 是很麻烦的,使用 CountDownLatch 就只需写一次,可以有效简化代码
有时候下载一个很大的文件,可以把它拆成多个部分,每个线程下载一小部分,当所有线程下载完之后,把下载的结果拼到一起,这称为“多线程下载”。下面模拟一下这个过程(注意 sleep 只是模拟下载时间而已)
public static void main(String[] args) throws InterruptedException {
CountDownLatch Latch = new CountDownLatch(5); //要等待 5 个线程
for(int i = 0;i < 5;i++) {
int n = i;
Thread t = new Thread(()->{
int time = (new Random().nextInt(5)+1) * 1000;
System.out.println("线程 " + n + " 开始下载");
try {
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程 " + n + " 下载完成");
Latch.countDown(); //告知 CountDownLatch 该线程执行完毕
});
t.start();
}
Latch.await(); //调用 await 来等待所有任务结束
System.out.println("所有任务执行完成");
}