并发编程——线程安全问题
前言
众所周知,Java中提供了许多线程安全的集合、操作类等,可能我们直接拿来用就行了,但是没搞懂什么时候用,为什么要用?那么这篇文章会帮助你!
线程不安全情况
线程不安全(Thread-unsafe)指的是在多线程环境中,某个操作或某些操作序列在多个线程并发执行时,不能保证数据的正确性和一致性,可能会产生意料之外的结果。简单来说,如果一个对象或资源在多个线程同时访问时,没有进行适当的同步措施,就可能导致线程不安全。最典型的案例:商品SKU超卖问题。
以下是线程不安全的一些常见原因和表现:
竞态条件(Race Conditions):当多个线程试图同时访问和修改同一数据时,如果没有适当的同步机制,那么最后的结果可能取决于线程的执行顺序,这种情况称为竞态条件。
数据不一致(Data Inconsistency):由于多个线程可以同时修改同一数据,如果没有适当的同步,可能会导致数据处于不一致的状态。
内存可见性(Memory Visibility):一个线程对共享变量的修改可能对其他线程不可见,这可能导致其他线程读取到旧值。
举个计数器的例子,如下
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class JUCDemo {
private final static int NUM_THREADS = 5;
private static int count = 0;
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(NUM_THREADS);
for (int i = 0; i < NUM_THREADS; i++) {
executorService.execute(() -> {
for (int j = 0; j < 1000; j++) {
count++;
}
});
}
//关闭线程池
executorService.shutdown();
//判断线程池是否终止,如果终止则打印count
while (!executorService.isTerminated()) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count = " + count);
}
}
通过Executor创建一个固定大小的线程池,对线程池循环执行5次,并将每个线程执行方法中count共享变量加1000次,大家猜想一下,最终打印的这个count会是多少呢?
答案是:小于等于5000
如果能想到小于等于5000,证明你的并发思想还是有点功底的,那么现在来解释一下,为什么会出现小于5000的情况。
这里不得不引申一下Java的内存模型了:
对于count变量来说,它是一个共享变量,储存在主内存中,当线程执行,每个线程会从主内存中copy一份副本到自己的本地内存中,所以每个线程中修改的不是主内存里的count,而是自己本地内存(也叫工作内存)的副本,修改完后,才会通过IO进行同步主内存里的count值。
如果线程1和线程2同时从主内存中copy一份count的副本,这时线程1和线程2本地内存中都是0,在各自的本地内存中执行加1操作后,同步到主内存,都是1,这就出现漏加的情况了,本来两次执行操作可以加2,结果现在只加了1。
如何防止线程不安全
这里就要说一下Java提供的那些线程安全的集合、操作类了,因为它们底层都做了加锁处理。最常见的就是synchronized 关键字和ReentrantLock类。
(1)synchronized :
synchronized 关键字: Java 中的每个对象都可以作为锁,使用它可以同步方法或代码块。当一个线程访问一个对象的 synchronized 方法或代码块时,它会获得该对象的内置锁,其他线程将无法同时访问该对象的任何 synchronized 方法或代码块,阻塞其他线程,当前线程执行完毕后才会释放锁,其他线程才会有机会获取锁。通俗的来讲:就好比共享资源是个公共厕所,好多人在厕所前排队,一个人进去后进行上锁,其他人只能等待。
下面是改造后的代码,这样就能保证count不会出现漏加的情况
Object lock = new Object(); // 创建一个锁对象
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(NUM_THREADS);
for (int i = 0; i < NUM_THREADS; i++) {
executorService.execute(() -> {
for (int j = 0; j < 1000; j++) {
synchronized (lock) {
count++;
}
}
});
}
}
synchronized 锁在JDK1.6之前,它的性能是很差的,但是经过JDK1.6之后,经过优化,它的性能得到很大提升。特别是加入了从偏向锁——轻量级锁——重量级锁的加锁过程,基本上synchronized 和ReentrantLock的性能是差不多的。
注意:synchronized 只能用在单体项目中,synchronized只能保证单个jvm内部的多个线程之间的互斥,如果在集群部署模型下,是会失效的,就要采用分布式锁来保证,比如:Redis的SETNX命令,zookeeper分布式唯一id等,后续会出一期关于使用Redis实现分布式锁的文章。
(2)ReentrantLock:
ReentrantLock其实就是基于AQS实现的一个可重入锁,支持公平和非公平两种方式。内部实现依靠一个state 变量和两个等待队列:同步队列和等待队列。利用 CAS自旋锁修改state来争抢锁,争抢不到则入同步队列等待,同步队列是一个双向链表。条件不满足时候则入等待队列等待,是个单向链表。
ReentrantLock因为是通过CAS自旋来实现的,不会阻塞线程,而是一直循环尝试获取锁,所以消耗较多CPU资源。
这里不再过多延伸,感兴趣的小伙伴可以查一下相关资料。下面是改造后的代码。
private static Lock lock = new ReentrantLock();// 创建一个ReentrantLock实例
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(NUM_THREADS);
for (int i = 0; i < NUM_THREADS; i++) {
executorService.execute(() -> {
for (int j = 0; j < 1000; j++) {
//加锁
lock.lock();
try {
count++;
} finally {
//锁释放
lock.unlock();
}
}
});
}
}
(3)原子操作:
Java为我们提供了一些原子操作类,在java.util.concurrent.atomic包下,来保证线程安全。常用的有:
//基本类型
AtomicBoolean //原子地更新布尔值。
AtomicInteger //原子地更新整数值。
AtomicLong //原子地更新长整数值。
//数组原子类
AtomicIntegerArray //原子地更新整型数组里的元素。
AtomicLongArray //原子地更新长整型数组里的元素。
AtomicReferenceArray //原子地更新引用类型数组里的元素。
为了解决上面案例的问题,下面就用AtomicInteger来演示
//创建原子操作类,默认为0
private static AtomicInteger count = new AtomicInteger(0);
publicstatic void main(String[] args) {
// 创建一个固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);
// 创建任务并提交给线程池执行
for (int i = 0; i < NUM_THREADS; i++) {
executor.execute(() -> {
for (int j = 0; j < INCREMENT_TIMES; j++) {
count.incrementAndGet(); // 原子性增加count的值
}
});
}
//关闭线程池
executorService.shutdown();
//判断线程池是否终止,如果终止则打印count
while (!executorService.isTerminated()) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("count = " + count);
}
(4)线程安全的集合类:
常用的线程安全集合有:ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet、Vector等,实际开发中根据业务场景选择合适的集合进行使用,保证线程安全。
总结
Java并发编程的知识是非常多的,而且苦涩难懂,不是一两句话能够说明白的,阿龙这篇文章只是让小伙伴对并发编程有个初步的认识,更多的底层实现和并发思想需要小伙伴们不断的自我补充。
OK!感谢观看,制作不易,点赞关注支持一下呦!