本文主要用于学习,详细参考:聊聊保证线程安全的10个小技巧前言 对于从事后端开发的同学来说,线程安全问题是我们每天都需要考虑的问题。 线程安全问题通 - 掘金
对于从事后端开发的同学来说,线程安全问题是我们每天都需要考虑的问题。
线程安全问题通俗的讲:主要是在多线程的环境下,不同线程同时读和写公共资源(临界资源),导致的数据异常问题。
比如:变量a=0,线程1给该变量+1,线程2也给该变量+1。此时,线程3获取a的值有可能不是2,而是1。线程3这不就获取了错误的数据?
线程安全问题会直接导致数据异常,从而影响业务功能的正常使用,所以这个问题还是非常严重的。
那么,如何解决线程安全问题呢?

1、不可变final
如果多线程访问的公共变量是不变的,也就不会存在线程安全的问题。
例如:
public class SafeUtils {
public static final String NAME = "Guoguo";
public void add(String param) {
System.out.println(name);
}
}
NAME被定义为static final的常量,在多线程情况下,不会被线程修改,也就是线程安全的。
2、无修改权限
如果我们定义了公共变量,但是没有暴露它的修改权限,这样也是线程安全的。
public class SafeUtils {
public String name = "Guoguo";
public String getName() {
return name;
}
public void add(String param) {
System.out.println(name);
}
}
这个demo中,我们只暴露了读取的权限,所以就不存在线程安全问题。
3、synchronized
synchronized是Java内部提供的同步机制,可以用于方法或代码块,同步方法的粒度有些大,所以我们优先使用同步代码块。
synchronized修饰方法或者代码块时,锁的是对象,这里有个面试点。
3.1、同步实例方法:锁的是当前的实例对象
public synchronized void add() {
// some code
}
synchronized修饰实例方法,锁的是当前的实例对象this。
-
如果多个线程试图调用同一个实例对象的add()方法,那么它们会彼此阻塞,直到前一个线程释放锁。
-
如果多个线程调用对象的不同实例的add()方法,它们不会互相阻塞,因为锁是加在不同实例上的。
在Spring中,默认情况下创建的bean 是单例的,只有一个,那么如果存在多个线程调用实例的add()方法时,就会存在锁竞争的情况。
3.2、同步静态方法:锁的是整个类的Class对象
public static synchronized void staticAdd() {
// some code
}
synchronized修饰静态方法,锁的是整个类的Class对象。
-
如果多线程访问该类的静态同步方法(即使是多实例的情况),它们都在争夺同一个锁(Class对象),同一时刻只有一个线程在执行这个静态同步方法。
这里有个概念,锁的是Class对象(例如:MyClass.class),给锁实例对象不同的是,每个类只有一个Class对象,但是可以有不同的实例。
3.3、同步代码块:可以指定任意对象作为锁
private final Object lock = new Object();
public void add() {
synchronized(lock) {
// some code
}
}
public void increment() {
synchronized(this) {
// some code
}
}
synchronized修饰同步代码块,可以指定任意对象作为锁。
-
如果使用synchronized(this),那么这个代码块给synchronized修饰实例方法一样,都是锁的当前实例对象。
-
如果synchronized(其他对象),则锁的对象是你指定的对象。多线程执行add()方法时,需要竞争同一个lock对象的锁,同一个时刻,只有一个线程可以进入同步代码块。
如果不同代码块使用了相同的锁对象,会怎么样呢?
例如:
public class MyClass {
private final Object lock = new Object();
public void method1() {
synchronized(lock) {
// 同步代码块1
}
}
public void method2() {
synchronized(lock) {
// 同步代码块2
}
}
}
如果一个线程已经持有了lock对象的锁,例如:正在执行method1方法的同步代码块,其他线程尝试进入method1、method2的同步代码块时,会被阻塞,直到前一个线程执行完同步代码块并释放锁。
如果不同代码块涉及不同的操作,可以考虑使用不同的锁对象,这些代码块之间的执行就不会互相影响,这样可以提高并发性,同时确保线程安全。
当代码块执行完之后,JVM底层就会自动的释放锁。
面试题:那如果在执行synchronized代码块的过程中,发生了异常,会释放锁吗?
会的,Java的synchronized机制保证了同步代码块中抛出异常,当前线程持有的锁也会在异常抛出后自动释放。
面试官又问:你了解synchronized底层的实现原理吗?
我emo了......回头搞它。感兴趣的小伙伴也可以自行学习下。
4、Lock锁
除了使用synchronized实现同步功能外,Java还提供了Lock锁。
通常我们会使用ReentrantLock锁,它是java.util.concurrent.locks包里的一个类,它是一种显式锁,我们需要在程序代码中显式获取或者释放锁(例如 调用lock()或者unlock())。
例如:
public class ExplicitLockExample {
private final Lock lock = new ReentrantLock();
public void criticalSection() {
lock.lock(); // 显式获取锁
try {
// 临界区代码
} finally {
lock.unlock(); // 确保释放锁
}
}
}
ReentrantLock显示锁提供了更高级的功能:
-
尝试锁(tryLock):tryLock()方法尝试获取锁,但如果锁不可用,则不会堵塞线程,可以执行超时时间,如果在超时时间内没有获取到锁,则方法返回false
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) { try { // 临界区代码 } finally { lock.unlock(); } }
-
中断支持(lockInterruptibly): 允许线程在等待锁时响应中断。
-
公平性策略(公平锁、非公平锁):可以配置为公平锁或非公平锁。公平锁按照线程请求锁的顺序来获取锁。
ReentrantLock fairLock = new ReentrantLock(true); // 创建公平锁 ReentrantLock unfairLock = new ReentrantLock(false); // 创建非公平锁
5、分布式锁
如果我们在单机的情况下,使用synchronized 和Lock处理线程安全是没问题的,如果是分布式环境的话,多个节点之间的线程安全就没法保证了。
这就需要使用:分布式锁了,例如我们可以使用redis分布式锁,或者Redisson。
@Autowired
private StringRedisTemplate redisTemplate;
public String deductStock() {
String key = "lock_key";
String value = "ID_PREFIX" + Thread.currentThread().getId();
try {
//key 要锁住的key;value 值;Duration.ofSeconds(60) 过期时间,单位:秒
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(60));
if (success) {
//获取到锁
}
} finally {
//currentValue.equals(value) 解决请求释放锁的时候,释放掉的不是自己锁的问题
String currentValue = redisTemplate.opsForValue().get(key);
if (currentValue != null && currentValue.equals(value)) {
//释放锁
redisTemplate.delete(key);
}
}
return "success";
}
和ReentrantLock一样,我们需要在finaly中手动释放锁。
有关redis或者Redisson的详细描述,可以翻阅《SpringBoot中如何使用Redis实现分布式锁?》。
6、volatile
有时候我们的需求场景可能比较简单,只需要多线程之间共享变量可见,此时我们就可以选择volatile。
例如:
private volatile boolean flag = true;
注意点:
-
volatile修饰的共享变量不能保证操作的原子性。
7、ThreadLocal
除了以上几种方案外,JDK还提供了另外一种 空间换时间的思路:ThreadLocal。
ThreadLocal的核心思想是:共享变量在每个线程都有一个副本,每个线程操作的都是自己的副本,对另外的线程没有影响。
public class ThreadLocalService {
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
public void add(int i) {
Integer integer = threadLocal.get();
threadLocal.set(integer == null ? 0 : integer + i);
}
}
ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
为什么要使用static修饰变量ThreadLocal呢?
-
可以共享同一个ThreadLocal实例
static修饰符使得ThreadLocal变量在整个类中只有一个实例,这就意味对象多实例的情况也都会共享同一个ThreadLocal变量。
在上述示例中,无论创建多少个ThreadLocalService,threadLocal变量都是共享的,但每个线程都有自己独立的副本。
-
避免对象的每个实例都创建ThreadLocal实例,减少了资源的浪费和重复创建
8、使用原子类
一般用atomic包下面的类即可,例如:AtomicInteger,底层是通过CAS操作保证了原子性,避免了加锁的开销。
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();
CAS 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁的情况下实现多线程之间的变量同步。
我们进入AtomicInteget源码,看下CAS是如何实现的。
public class AtomicInteger extends Number implements java.io.Serializable {
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
}
public final class Unsafe {
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
@IntrinsicCandidate
public final boolean weakCompareAndSetInt(Object o, long offset,
int expected,
int x){
return compareAndSetInt(o, offset, expected, x);
}
}
以上的源码来自JDK17,从源码中可以看出incrementAndGet方法的主要逻辑在Unsafe.getAndAddInt()方法中,该方法的主要逻辑如下:
-
getIntVolatile():通过偏移量offset从对象o中获取字段的当前值,赋值给v
-
weakCompareAndSetInt():如果该方法返回true,则终止循环,返回当前值v,否则循环获取字段的当前值v,通过weakCompareAndSetInt匹配,直到成功,返回true 终止。
那么weakCompareAndSetInt()到底是干什么呢?为什么返回true,才能结束循环?
我们先来看下weakCompareAndSetInt()方法的几个入参:
-
o:对象o
-
offset:偏移量
-
expected:将当前值v赋值为expected作为期望值
-
x:v+delta 作为新值,其中入参中delta设置的是1
weakCompareAndSetInt()方法中,通过偏移量offset从对象o中获取字段的当前值,然后比较当前值和传入的期望值是否相等,如果相等,则将字段的值更新为x,返回true,如果不相等,则不会执行更新操作,返回false。
有小伙伴就会有疑问了,你不是才通过getIntVolatile获取过当前值,并且作为了期望值expected传入了weakCompareAndSetInt,那么当前值和期望值肯定相等啊!其实不然。
weakCompareAndSetInt中会再次通过偏移量offset从对象o中获取字段的当前值,如果这期间字段值被其他线程修改,那么此时获取到的当前值就不等于期望值,就不会执行更新操作,只有确保在修改字段值之前,字段的当前值与预期值依然相等,才能执行更新操作,从而确保了原子操作的安全性。
9、线程安全的集合
有时候,我们需要把公共资源放到某个集合中,例如:ArrayList,HashMap。
在多线程环境中,如果A线程正在从集合中读取数据,B线程正在往集合中写数据,这样就可能会出现线程安全问题。
为了解决集合的线程安全问题,JDK专门给我们提供了能够保证线程安全的集合,例如:CopyOnWriteArrayList、ConcurrentHashMap。
public class SafeUtils {
private static ConcurrentHashMap<String, String> hashMap = new ConcurrentHashMap<>();
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
hashMap.put("1","guoguo");
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
hashMap.put("2","lisan");
}
}).start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(hashMap);
}
}
我们聊ConcurrentHashMap是线程安全的,并能解决多线程并发的安全问题。
例如:在多线程环境中,如果A线程正在从集合中读取get()数据,B线程正在往集合中写put()数据。
这里有个特别注意的点:
ConcurrentHashMap的单个操作(如put,get,remove)是线程安全的,但是多个操作的组合却可能不安全,例如:
if (!map.containsKey(key)) {
map.put(key, value);
}
如果理解呢?
-
1、单独操作:上述我们的demo中,出现了两个线程A、B,它们分别调用get()、put()方法,虽然看起来像是在组合操作,但实际上对于单个线程而言,这是单独操作,是线程安全的。
-
2、组合操作:如果在同一个线程中调用了get()和put()方法,并且它们之间存在逻辑上的依赖关系,那么这些操作就构成了一个组合操作,在多线程环境可能是非原子性的。
如何解决非原子问题?
-
1、使用原子操作:通过使用ConcurrentHashMap的原子方法(例如putIfAbsent,computeIfAbsent())来保证多个操作的原子性。
-
2、加锁:如果多个操作必须作为一个整体执行,可以在外部使用显示锁(如ReentrantLock)来保证操作的原子性。

195

被折叠的 条评论
为什么被折叠?



