面试官:加锁就一定线程安全了吗?

我们都知道,当多个线程并发地操作同一共享资源的时候,容易发生线程安全问题,解决这个问题的一个办法是加锁,那么问题来了:加锁就一定线程安全了吗?

各位小伙伴,你们的答案是什么?是,还是不是?

其实这种面试问题,面试官可能会希望你能根据不同的场景展开阐述,而不是简单的回答是或不是,这既可表现出你对多线程中的线程安全问题的理解到位,同时也体现了你分析问题的能力比别的候选人强,考虑问题周到。

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,执行结果如下:

1.png

结果正确,线程安全。

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);
    }
}

执行结果如下:

2.png

可以看到,结果并不正确,线程不安全。

那这是为什么呢?其实就是因为这里有两把锁,不同的锁,也就不能保证多线程对同一共享资源的并发操作是线程安全的。也就是说 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();
        }
    }
}

执行结果:

3-1.png

乍一看,这不是正确的吗?别急,我们再来加点东西看看:

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();

如以上代码,在前面的代码基础上,⑴ 处第一次获取到读锁后,在释放读锁之前,对共享资源进行了修改,执行结果如下:

3-2.png

可以看到,因为在读锁区域内对共享资源进行了修改,导致出现了线程安全问题,而这种问题是由于不正确地使用了读写锁导致的。也就是说,在使用读写锁时,不能在读锁范围内对共享资源进行“写”操作,需要理解读写锁的适用场景并且正确地使用它。

总结

这次通过一个面试题,简单地梳理了一下多线程的线程安全问题与锁的关系,希望对各位能有帮助!由于个人能力所限,如果各位小伙伴在阅读文章时发现有错误的地方,欢迎反馈给我勘正,万分感谢。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容