前言
线程安全问题一直是并发编程中比较让人头疼的问题.
通过前面的学习, 我们知道:
线程之所以不安全, 主要是多线程下对可变的共享资源的争用导致的.
衡量线程是否安全, 主要从三个特性入手
-
原子性
-
可见效
-
有序性
只要保证了这三个特性,我们就认为线程是安全的, 多线程下执行结果才会和单线程执行结果统一起来.
本章,我们就来聊聊”如何保证线程安全“的问题.
如何保证原子性
常用的保证Java操作原子性的工具是"锁和同步方法(或者同步代码块)".
我们举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class Test { private static int count = 0 ; public static void addCount() { count++; } public static void main(String[] args) throws Exception { for ( int i = 0 ; i < 10 ; i++) { Thread thread = new Thread( new Runnable() { @Override public void run() { for ( int j = 0 ; j < 1000 ; j++) { addCount(); } } }); thread.start(); } // 主线程睡眠1s,保证子线程都执行完毕 Thread.sleep( 1000 ); System.out.println( "count=" + count); } } |
可以看出,
子线程计数器累加到1000,
然后主线程创建了10个子线程来跑,
所以,最终结果是应该是10000,
但是大家运行代码看看, 发现各种错误的输出都有!
原因就是 “count++”这个操作不是我们以为的“原子操作“, 它其实是三步操作
-
从主存中读取count的值,复制一份到CPU寄存器
-
CPU寄存器中,CPU执行指令对 count 进行加1 操作
-
把count重新刷新到主存
单线程当然没有问题, 但当多线程时, 就会存在问题.
所以我们必须解决这个问题!
保证原子性 - 锁
使用锁, 可以保证 同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码.
使用方式:
// 声明一个锁
private static ReentrantLock lock = new ReentrantLock();
public static void addCount() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
需要强调的是记得 finally 释放锁,防止异常导致锁一直无法释放!
try{
//加锁代码
}finally{
lock.unlock();
}
保证原子性 - 同步方法
与锁类似的是同步方法或者同步代码块,Java使用关键字synchronized进行同步.
需要注意的是, synchronized是有作用范围的.
synchronized的作用范围:
-
修饰非静态方法(或成员变量),锁的是this对象, 就是类的实例对象(即: 对象锁)
public synchronized void addCount() {
count++;
}
-
修饰静态方法(或成员变量), 锁的是Class对象本身, 因为静态成员不专属于任何一个实例对象 (即: 类锁)
public static synchronized void addCount() {
count++;
}
-
修饰代码块时, 锁住的是synchronized关键字后面括号内的对象.
public class Test{
private Object object = new Object();
public void addCount() {
//此时,锁住的是object对象变量
synchronized (object) {
count++;
}
//此时锁住的是当前实例对象
synchronized (this) {
count++;
}
//此时锁住的是当前Test类的class对象
synchronized (Test.class) {
count++;
}
}
}
无论使用锁还是synchronized, 本质都是一样
通过锁或同步来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性.
既然提到了锁,我们就不得不提下“悲观锁、乐观锁、CAS无锁以及可重入锁”的概念, 这些知识点也是面试中常出现的, (参见后文补充知识点)
如何保证可见性
Java提供了volatile关键字来保证可见性.
当使用volatile修饰某个变量时,
它会保证对该变量的修改会立即被更新到内存中,
并且将其它线程缓存中对该变量的缓存设置成无效
因此其它线程需要读取该值时必须从主内存中读取,
从而得到最新的值.
我们还举介绍可见性时的例子,
private static volatile boolean isRuning = false;
如果用volatile来修饰isRuning, 再运行你会发现, 程序能得到预期结果了.
volatile适用于不需要保证原子性,但却需要保证可见性的场景,一种典型的使用场景是用它修饰用于停止线程的状态标记
关于“不需要保证原子性”这点, 大家可以参考介绍“原子性”的那个案例(多线程count++), 将count定义为volatile修饰的变量
private static volatile int count = 0;
运行你会发现最终结果并不是预期值, 原因就在于:
两个线程A,B同时进行count++,
count++是三步操作
1. 从主存中读取count的值,复制一份到CPU寄存器
2. CPU寄存器中,CPU执行指令对 count 进行加1 操作
3. 把count重新刷新到主存
假设count = 1
A和B读取count都是1,复制到各自的缓存中
假设A先执行完了, 将count = 2回写进主存,
因为volatile, 所以通知其它线程count值有更新.
B呢,此时正好执行到最后一步,于是保存的是2,而不是我们认为的3!
如何保证有序性
针对编译器和处理器对指令进行重新排序时,可能影响多线程程序并发执行的正确性问题,
Java中可通过volatile关键字在一定程序上保证顺序性,另外还可以通过锁和同步(synchronized)来保证顺序性.
事实上, 锁和synchronized即可以保证原子性,也可以保证可见性以及顺序性.因为它们是通过保证同一时间只有一个线程执行目标代码段来实现的
锁和synchronized可以“胜任”一切,为什么还需要volatile?
synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高;
而volatile开销小很多,
因此在只需要保证可见性的条件下,
使用volatile的性能要比使用锁和synchronized高得多.
除了从应用层面保证目标代码段执行的顺序性外,
JVM还通过被称为happens-before原则隐式地保证顺序性.
两个操作的执行顺序只要可以通过happens-before推导出来,
则JVM会保证其顺序性,
反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率.
补充知识 - happens-before
在JMM(Java内存模型)中,
如果一个操作的执行结果需要对另一个操作可见,
那么这两个操作之间必须要存在happens-before关系,
这两个操作既可以在同一个线程,也可以在不同的两个线程中
我们需要关注的happens-before规则如下:
-
传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,
则可以得出操作A先行发生于操作C
-
锁定规则
一个unlock操作肯定会在后面对同一个锁的lock操作前发生,
锁只有被释放了才会被再次获取
-
volatile变量规则
对一个volatile修饰的变量的写操作先行发生于后面对这个变量的读操作
-
程序次序规则
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
-
线程启动规则
Thread对象的start()方法先发生于此线程的其它动作
-
线程终结原则
线程中所有的操作都先行发生于线程的终止检测,
我们可以通过Thread.join()方法结束,
Thread.isAlive()的返回值手段检测到线程已经终止执行
(所有终结的线程都不可再用)
-
线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
-
对象终结规则
一个对象的初始化完成先行发生于他的finalize()方法的开始
补充知识 - 悲观锁和乐观锁
-
悲观锁
处理数据时,假设会有其他外部修改,所以每次都会锁住数据, 防止外部的操作.
-
乐观锁
处理数据时,不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止.
初一看, 大家可能会任务乐观锁好像比悲观锁性能高,其实也要看具体场景!
因为乐观锁的重试机制, 所以当并发量很高的时候, 重试的次数就会剧增, 此时, 显然性能是不如悲观锁的!
显而易见, 锁或同步就是悲观锁, 它们以"牺牲性能"来保证原子性.
那么, 有没有无需加锁也能保证原子性的方式呢?
补充知识 - CAS(无锁)
CAS 是英文单词 Compare And Swap 的缩写,翻译过来就是比较并替换.
CAS有3个操作数,内存值V, 旧的预期值A,要修改的新值B.
当且仅当预期值A和内存值V相同时, 将内存值V修改为B,否则什么都不做.
我们举个例子:
假设V = 10;
线程1想要使得V的值加1, 按CAS,
此时, A=10, B = 11;
线程2突然修改了V=11;
线程1发现, (A=10) != (V=11), 所以, 不允许更新!
CAS是一种乐观锁的机制,它不会阻塞任何线程. 所以在效率上,它会比 锁和同步要高.
上文中我们说“count++”自增操作不是原子的, 这导致了并发问题, 那么如何解决呢?
Java提供了并发原子类AtomicInteger来解决自增操作原子性的问题,其底层就是使用了CAS原理
1 2 3 4 | private static AtomicInteger count = new AtomicInteger(); public static void addCount() { count.incrementAndGet(); } |
CAS虽然在普通场景下优于锁和同步, 但是同时引入了一个“ABA”问题!
ABA问题:
我们还举上一个例子:
假设V = 10;
线程1想要使得V的值加1, 按CAS, 此时, A=10, B = 11;
线程2突然修改了V=11;
线程3突然修改了V=10;
线程1发现, (A=10) = (V=10), 所以, 允许更新!
虽然数字结果上没有问题, 但是如果需要追溯过程就会存在漏洞!
因为CAS把线程3修改的V=10,当成了V的初始值10, 认为它从未更改过!
针对ABA问题,虽然也能通过增加版本号等等来解决, 不过有句忠告:
使用CAS要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用锁或同步可能更高效
补充知识 - 可重入锁
介绍完以上知识,不知道大家关于“锁”的使用,有没有这样的疑惑
A线程对某个对象加锁后,
在A线程内部如果再次要获取同一个对象的锁,会怎样?
会不会死锁?
针对这样的问题, 提出了可重入锁这个东西!
所谓可重入锁,指的是以线程为单位,
当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,
而其他的线程是不可以的.(同一个加锁线程自己调用自己不会发生死锁情况)
可重入锁是为了防止死锁
它的实现原理是
通过为每个锁关联一个请求计数和一个占有它的线程.
当计数为 0 时,认为锁是未被占有的.
线程请求一个未被占有的锁时, jvm将记录锁的占有者,并且将请求计数器置为1 .
如果同一个线程再次请求这个锁,计数将递增;
每次占用线程退出同步块,计数器值将递减.
直到计数器为0,锁被释放.
synchronized 和 ReentrantLock 都是可重入锁
-
ReentrantLock 表现为 API 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成);
-
synchronized 表现为原生语法层面的互斥锁.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)