Java并发编程入门(十八)再论线程安全

1,766 阅读5分钟

banner窄.png

铿然架构  |  作者  /  铿然一叶 这是铿然架构的第 48 篇原创文章

相关阅读:

Java并发编程(一)知识地图
Java并发编程(二)原子性
Java并发编程(三)可见性
Java并发编程(四)有序性
Java并发编程(五)创建线程方式概览
Java并发编程入门(六)synchronized用法
Java并发编程入门(七)轻松理解wait和notify以及使用场景
Java并发编程入门(八)线程生命周期
Java并发编程入门(九)死锁和死锁定位
Java并发编程入门(十)锁优化
Java并发编程入门(十一)限流场景和Spring限流器实现
Java并发编程入门(十二)生产者和消费者模式-代码模板
Java并发编程入门(十三)读写锁和缓存模板
Java并发编程入门(十四)CountDownLatch应用场景
Java并发编程入门(十五)CyclicBarrier应用场景
Java并发编程入门(十六)秒懂线程池差别
Java并发编程入门(十七)一图掌握线程常用类和接口
Java并发编程入门(十九)异步任务调度工具CompleteFeature
Java并发编程入门(二十)常见加锁场景和加锁工具


1. volatile的作用

1.禁止进行指令重排。

2.保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。如下图所示:

转存失败,建议直接上传图片文件
a.线程工作时会将数据加载到工作内存操作,对于不加volatile关键字修饰的变量,线程A修改后,线程B不会立即到主存中读取。

b.以下三种情况线程B才会去刷新主存数据
1)线程中释放锁时
2)线程切换时
3)CPU有空闲时间时(比如线程休眠,IO操作)

2. 对volatile的误解

很多人以为volatile是线程安全的,但实际上volatile只能保证可见性,A线程写入后,B线程能立即看到,并不能保证线程安全。

2.1 错误例子

public class ErrorVolatileDemo {
    private static class Worker implements Runnable {
        private static volatile int count = 0;

        @Override
        public void run() {
            //线程安全的话,最终数字应该是40000,实际却不是
            for (int i = 0; i < 20000; i++) {
                count = count + 1;    // 非原子操作,导致结果不正确
            }
            System.out.println("count: " + count);
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new ErrorVolatileDemo.Worker());
        Thread t2 = new Thread(new ErrorVolatileDemo.Worker());

        t1.start();
        t2.start();
    }
}

两个线程循环20000次,线程安全情况下,期望值为40000,而实际打印日志:

count: 22701
count: 30116

可见volatile关键字只能保证可见性,不能保证线程安全。

2.2 线程安全例子

public class CorrectVolatileDemo {
    private static class Worker implements Runnable {
        public static volatile int count = 0;

        @Override
        public void run() {
            for (int i = 0; i < 20000; i++) {
                synchronized (Worker.class) {  // 同步加锁保证原子性,且java对象为类,而不是类实例
                    count = count + 1;
                }
            }
            System.out.println("count: " + Worker.count);
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new CorrectVolatileDemo.Worker());
        Thread t2 = new Thread(new CorrectVolatileDemo.Worker());

        t1.start();
        t2.start();
    }
}

打印日志:

count: 39258
count: 40000

可以看到使用synchronized加锁后,最终打印结果为4000,前面一个不为4000是因为第1个线程执行完后退出,第2个线程还未执行完。

对于这个例子而言,实际不需要使用volatile修饰count变量能保证线程安全。(可以去掉volatile再跑一次试试)

2.3 可见例子

那么volatile的可见性怎么体现,再来看一个例子:

public class VolatileVisibilityDemo {

    private static class Worker implements Runnable {
        private boolean stop = false;  // 不使用volatile关键字时,在线程内修改取值,主线程看不到,因此即使取值变化,主线程也不会停下来

        @Override
        public void run() {
            quietlySleep();
            stop = true;
            System.out.println("stop =" + stop);
        }

        public boolean isStop() {
            return stop;
        }

        private void quietlySleep() {
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                // nothing to do
            }
        }
    }

    public static void main(String[] args) {
        Worker td = new Worker();
        new Thread(td).start();
        while (true) {   // 没有volatile修饰时,且cpu没有空闲,不会重新加载子线程更新到主内存的变量,无法读取到修改后的取值
            if (td.isStop()) {
                System.out.println("exit");
                break;
            }
        }
    }
}

打印日志:

stop =true

可以看到,虽然日志中stop取值已经是true,但是主线程并没有退出,是因为主线程中的while(true)一直在占用CPU,导致没有空闲时间刷新主内存数据获得Worker更新的最新值。如果要想刷新主内存数据有几种方式:
1.stop变量增加volatile关键字修饰,那么每一次都会到主内存中读取数据。
2.在while(true)代码块中释放CPU,例如sleep,通过System.out打印日志,文件操作等等。

当然通常情况下不会写个while条件始终为true,而在其代码块中又不做休眠处理,因此这类问题一般不会出现,但是为了避免出现类似问题,凡是跨线程获取变量取值的操作最好都通过volatile关键字修饰。

接着用volatile修饰stop再跑一次,打印日志:

exit
stop =true

此时主线程可以看到Worker更新到主内存中的变量值,因此主线程退出,这就是可见性。

3. volatile的其他使用场景

3.1. 单例模式

除了上面的可见性例子外,volatile使用的经典场景是单例模式,如下是单例模式的其中一种标准写法:

public class Singleton {
    private static volatile Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {
        if (null == singleton) {                  // 通过volatile的可见性提高性能
            synchronized (Singleton.class) {      // 减小锁的范围
                if (null == singleton) {          // 通过volatile的可见性保证不会重复实例化
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

4. 小结

1.单独使用volatile关键字只能保证可见性,不能保证原子性,因此不能确保线程安全。

2.在某些场景下,只需要满足线程间的可见性要求,此时不需要通过加锁来实现原子性以确保线程安全,这种场景下只需要读取到其他线程更新后的最新值则可。

3.加锁也能做到可见性,但是加锁有性能损耗,因此仅通过volatile来做到可见性是有意义的。

4.并非所有场景下volatile和加锁要同时使用,两者均有单独使用的场景,也有一起使用的场景。


<--阅过留痕,左边点赞!