线程安全问题 是一个重难点,编程就是这样,有的时候自己无论如何苦思冥想也弄不明白,但如果有人指点一二就能豁然开朗,希望本文可以给各位同学带来帮助
本文作者: csdn 孟秋与你
文章目录
- 如何判断一个类是否线程安全
- 是否有共享的可变状态:
- 是否使用线程安全的数据结构:
- 是否使用了同步机制:
- 是否声明为final不可变
- 某些简单场景下声明为volatile
- 事务与线程安全
- 锁String字符串
- 锁优化
- 什么是锁重用、锁竞争、分段锁
如何判断一个类是否线程安全
-
是否有共享的可变状态(全局变量 即堆内存)
-
是否使用了线程安全的数据结构
-
是否使用了同步机制
-
是否声明为final 不可变
-
某些简单场景下声明为volatile(不理解可以忽这点)
是否有共享的可变状态:
如下面代码 除了get方法使用到age变量,没有其它任何地方修改这个值,所以自然是线程安全的
public class User{
private Integer age;
public Integer getAge(){
return this.age;
}
}
是否使用线程安全的数据结构:
比如某个类使用的都是ConcurrentHashMap存放全局变量,那也是线程安全的。
我们接着往下看,如何确定一个数据结构是否安全的
是否使用了同步机制:
上面提到的线程安全的数据结构,这些数据结构通常也是通过同步机制来保证线程安全的,我们简单看几个jdk自带几个线程安全类:
首先是大家最熟悉的ConcurrentHashMap,通过synchronized关键字 对node加锁
还有远古时期八股文常见面试题之一的hashtable(现在应该没人问了吧) 通过synchronized把整个方法都锁了,因为有性能问题 所以后面大家都不用了
我们再来看看一个双向队列类:LinkedBlockingDeque
看源码可知,涉及修改的地方都使用了ReentrantLock加锁,所以它也是线程安全的。
是否声明为final不可变
比如下面类,它也是线程安全的
public class Test(){
private final People people;
public void method(){
// do something
}
}
某些简单场景下声明为volatile
(关于volatile关键字 博主用了些时间才略有所懂,私认为如果不能掌握这个关键字用法 我们在写代码时没必要硬用)
volatile比synchronized轻量很多,但volatile只保证可见性、有序性,并不保证原子性,所以它并不能完全保证线程安全。
如下面代码
public class Test{
private volatile int age;
}
age如果涉及到age++操作,就会出现线程不安全的情况,因为age++ 实际上是三个步骤:读取、修改、写入。
那么什么场景下会用到volatile呢?
当需要使用一个简单的标志位来控制线程的执行状态时,volatile 是一个理想的选择。例如,使用 volatile 来控制一个线程的停止信号。
(简而言之boolean已经是基本类型了,它内部没有其它变量,也没有int这样的原子性操作)
public class VolatileFlagExample {
private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
// 执行任务
}
System.out.println(" 作者-csdn: 孟秋与你");
}
public static void main(String[] args) throws InterruptedException {
VolatileFlagExample example = new VolatileFlagExample();
Thread thread = new Thread(example::run);
thread.start();
Thread.sleep(1000); // 模拟运行一段时间
example.stop();
}
}
另外 单例模式也会用到:
在单例模式中,使用 volatile 可以防止指令重排序问题,确保单例实例的正确初始化和可见性。
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
// 私有构造函数
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
事务与线程安全
事务是绑定在线程上面的 不同线程无法使用同一个事务
(注意本文讨论的只是线程安全 但完全可能有并发问题 导致ABA)
锁String字符串
示例:锁String a
public void input(String a){
synchronized(a)
// do other
}
使用sync(a) 在http中请求会有问题 ,
因为Spring框架中会变成一个对象new String(a)
,
每次锁的a 都是不同对象,导致锁失效
要解决这个问题 可以锁 a.intern() ,这样锁的是字符串常量
但是假如有另外一个方法:
public void output(String a) {
// do something
}
就会导致 我们预期只需要锁input,结果output方法也被锁了。
锁优化
我们知道synchronized是比较重的,
public synchronized void test(String a){
// do something
}
public void test(String a){
synchronized(this){
// do something
}
}
锁方法和锁this是等效的,都是锁当前对象;所有用到当前对象的地方都会被锁住
我们可以优化一下,由锁当前对象变成锁一个对象,如下面代码 就只是锁住了lock对象
Object lock = new Object();
public void test(String a){
synchronized(lock){
// do something
}
}
但是上面方法并发性不是很高, 不同的入参需要串行等待,
我们可以使用如下方法:
public class Test {
ConcurrentHashMap lock = new ConcurrentHashMap();
public void input(String a) {
lock.computeIfAbsent(a,item -> new Object());
Object obj = lock.get(a);
synchronized (obj) {
}
}
public void outPut(String a) {
// do something
}
}
这样不同的入参就持有不同的锁,可以并行运行了,每个入参执行互不干扰。
(但如果数据不断激增可能导致锁泄露, oom情况)
什么是锁重用、锁竞争、分段锁
上文我们提到 通过ConcurrentHashMap lock的方式可能出现oom,如果要自己去维护value,会非常的费劲;我们可以使用guava提供的一种折中方案:
通过这个方案也顺便解释一下八股文里面常见的术语
import com.google.common.util.concurrent.Striped;
import java.util.concurrent.locks.Lock;
public class Test {
// 预设锁的数量 根据实际并发量调整
private final Striped<Lock> stripedLocks = Striped.lock(1024);
public void input(String a) {
Lock lock = stripedLocks.get(a);
lock.lock();
try {
// 处理与键 'a' 相关的逻辑
System.out.println("Processing input for key: " + a);
// 模拟处理时间
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} finally {
lock.unlock();
}
}
public void outPut(String a) {
// do something
}
}
Striped lock底层也是通过ReentrantLock来实现的,Striped的API要求初始必须传入指定的锁数量,这避免了锁溢出情况。
当我们指定Striped.lock(1024)时,如果并发是5000 远大于这个数量会发生什么呢?
我们简单看一下stripedLocks.get(a)的源码:
通过hash计算,来获取索引
所以Striped锁的原理也比较明了:指定数量的锁,比如指定了1024把锁,当我们传入参数时,对参数进行hash计算,获取其中一把锁;相同hash会获取同一把锁(锁重用),如果锁没释放 则会进入等待。
下面再用大白话解释一遍:
stripedLocks.get(a) -> 其实是对a进行hash计算 ,我们可以理解为
stripedLocks.get(hash(a))
等a执行完,锁释放,但这把锁不会消失;后面某参数 hash(xxx) 可能获取到这把相同的锁, 如果hash(abc) 和 hash(xyz) 计算结果一样,那么就出现了锁竞争的情况,只有一个线程能执行,另一个线程陷入等待。
看到这里应该明白了,stripedLocks的逻辑 就类似于我们在代码中
Object obj1 = new Object();
// ...
Object obj1024 = new Object();
// 计算hash值 获取其中一个对象 objx
public void test(String a){
synchronized (objx){
// do something
}
}
它虽然不会出现oom的情况,但锁竞争概率大于ConcurrentHashMap lock的方式,所以博主说它是一种折中的方式,这种方式在专业术语中称为分段锁。
通过这个Striped锁的示例,博主浅用自己通俗的话总结:可以被不同对象重复使用称为锁重用,正是因为锁可以重用,所以会产生竞争;竞争时只有一个线程可以执行,其它线程需要等待,这种现象就是锁竞争,而尽可能的减少锁竞争的方式,就是尽可能的通过hash等算法打散,去获取不同的锁,这种方式就是分段锁