我们都知道,当多个线程并发地操作同一共享资源的时候,容易发生线程安全问题,解决这个问题的一个办法是加锁,那么问题来了:加锁就一定线程安全了吗?
各位小伙伴,你们的答案是什么?是,还是不是?
其实这种面试问题,面试官可能会希望你能根据不同的场景展开阐述,而不是简单的回答是或不是,这既可表现出你对多线程中的线程安全问题的理解到位,同时也体现了你分析问题的能力比别的候选人强,考虑问题周到。
1. 加同一个内置锁或者显式独占锁,一定线程安全
这种方式实际上是将并行变成了串行,所有需要进入同步区的线程,都需要先获取到这把锁,一旦某个线程获取到了锁,其他线程就需要等待,即同时间在同步区范围内,只能允许一个线程进行共享资源的访问,因此会降低性能!
1) 加同一个内置锁
import java.util.concurrent.CountDownLatch;
public class ThreadSafeDemo {
private int anInt = 0;
public synchronized void incr() {
anInt++;
}
public void decr() {
synchronized (this) {
anInt--;
}
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
if (threadIdx % 2 == 0) { // threadIdx 等于 0、2、4 时
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
demo.incr();
}
latch.countDown();
}).start();
} else { // threadIdx 等于 1、3 时
new Thread(() -> {
for (int i = 10000; i > 0; i--) {
demo.decr();
}
latch.countDown();
}).start();
}
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 期望值:10000
System.out.println("当前 anInt 的值为:" + demo.anInt);
}
}
如以上代码,开启 5 个并发线程,其中 3 个线程分别自增 10000,2 个线程分别自减 10000,所以最终期望正确的值应该是 30000 - 20000 = 10000,执行结果如下:
结果正确,线程安全。
2) 加同一个显式独占锁
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadSafeDemo {
private int anInt = 0;
public void incr() {
anInt++;
}
public void decr() {
anInt--;
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ReentrantLock lock = new ReentrantLock();
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
if (threadIdx % 2 == 0) { // threadIdx 等于 0、2、4 时
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
// 显式独占锁加锁
lock.lock();
demo.incr();
// 显式独占锁解锁
lock.unlock();
}
latch.countDown();
}).start();
} else { // threadIdx 等于 1、3 时
new Thread(() -> {
for (int i = 10000; i > 0; i--) {
// 显式独占锁加锁
lock.lock();
demo.decr();
// 显式独占锁解锁
lock.unlock();
}
latch.countDown();
}).start();
}
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 期望值:10000
System.out.println("当前 anInt 的值为:" + demo.anInt);
}
}
同 1) 一样,只不过这里换成了显式的独占锁(ReentrantLock
),所以执行结果是一样的!
2. 加不同的锁,一定线程不安全
我们对 1 中的内置锁部分代码做一些修改,注意 incr()
和 decr()
方法:
import java.util.concurrent.CountDownLatch;
public class ThreadSafeDemo {
private static int anInt = 0;
public synchronized void incr() {
anInt++;
}
public static synchronized void decr() {
anInt--;
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
if (threadIdx % 2 == 0) { // threadIdx 等于 0、2、4 时
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
demo.incr();
}
latch.countDown();
}).start();
} else { // threadIdx 等于 1、3 时
new Thread(() -> {
for (int i = 10000; i > 0; i--) {
ThreadSafeDemo.decr();
}
latch.countDown();
}).start();
}
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 期望值:10000
System.out.println("当前 anInt 的值为:" + anInt);
}
}
执行结果如下:
可以看到,结果并不正确,线程不安全。
那这是为什么呢?其实就是因为这里有两把锁,不同的锁,也就不能保证多线程对同一共享资源的并发操作是线程安全的。也就是说 0、2、4 线程获取的锁跟 1、3 线程获取的锁不是同一个锁,0、2、4 线程获取的锁作用的对象是调用 incr()
这个方法的对象,也就是 demo
,而 1、3 线程获取的锁作用的对象是 ThreadSafeDemo
这个类的 Class
对象,跟 synchronized (ThreadSafeDemo.class) {...}
的作用是类似的。
3. 加同一读写锁,不一定线程安全
1 中使用的是独占锁,会降低性能。实际上在一些场景下,多线程也可以同时访问共享资源,而不会产生线程安全的问题。例如多线程的“读”操作与“读”操作之间。
下面以 Java 8 的 ReentrantReadWriteLock
例子作示例说明,该示例参考了 Oracle 官方的 API 文档中的例子,>> 传送门:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ThreadSafeDemo {
/**
* 数据
*/
private String data = null;
/**
* 缓存是否有效
*/
private volatile boolean cache = false;
public String getDataFromDb() {
// 模拟从数据库中获取数据,耗时 0.5 秒
String data = null;
try {
TimeUnit.MILLISECONDS.sleep(500L);
data = String.valueOf(System.currentTimeMillis());
System.out.println("[" + Thread.currentThread().getName()
+ "] 缓存无效,从数据库中获取数据:" + data);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return data;
}
public void use() {
System.out.println("[" + Thread.currentThread().getName()
+ "] 当前 data 的值为:" + data);
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
new Thread(() -> {
// 获取读锁:⑴
rwLock.readLock().lock();
// 如果缓存无效
if (!demo.cache) {
// 释放读锁(读锁不能升级为写锁):⑴ 处获取的
rwLock.readLock().unlock();
// 获取写锁
rwLock.writeLock().lock();
try {
// 再次检查缓存是否有效,因为其他线程有可能先于当前线程获取到写锁并修改了它的值
if (!demo.cache) {
demo.data = demo.getDataFromDb();
// 缓存设为有效
demo.cache = true;
}
// 获取读锁(在释放写锁之前,再获取读锁,进行锁降级):⑵
rwLock.readLock().lock();
} finally {
// 释放写锁,此时线程仍持有读锁(⑵ 处获取的)
rwLock.writeLock().unlock();
}
}
try {
// 模拟 1 秒的处理时间,并打印出当前值
TimeUnit.SECONDS.sleep(1);
demo.use();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放读锁:⑴ 或 ⑵ 处获取的
rwLock.readLock().unlock();
}
latch.countDown();
}).start();
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
执行结果:
乍一看,这不是正确的吗?别急,我们再来加点东西看看:
new Thread(() -> {
// 获取读锁:⑴
rwLock.readLock().lock();
// 如果缓存无效
if (!demo.cache) {
// 错误示范,在读锁里面修改了数据
demo.cache = true;
demo.data = demo.getDataFromDb();
demo.cache = false;
// 释放读锁(读锁不能升级为写锁):⑴ 处获取的
rwLock.readLock().unlock();
// Omit code...
}
// Omit code...
}).start();
如以上代码,在前面的代码基础上,⑴ 处第一次获取到读锁后,在释放读锁之前,对共享资源进行了修改,执行结果如下:
可以看到,因为在读锁区域内对共享资源进行了修改,导致出现了线程安全问题,而这种问题是由于不正确地使用了读写锁导致的。也就是说,在使用读写锁时,不能在读锁范围内对共享资源进行“写”操作,需要理解读写锁的适用场景并且正确地使用它。
总结
这次通过一个面试题,简单地梳理了一下多线程的线程安全问题与锁的关系,希望对各位能有帮助!由于个人能力所限,如果各位小伙伴在阅读文章时发现有错误的地方,欢迎反馈给我勘正,万分感谢。