保证线程安全的处理策列

本文主要用于学习,详细参考:聊聊保证线程安全的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)来保证操作的原子性。

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

涵冰...

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值