线程安全之可见性的根本原因

1 篇文章 0 订阅

目录

1、并发中变量的可见性问题

2、怎样才能可见

2.1、使用synchronized

2.2、使用volatile

3、变量可见性、线程安全问题原因

3.1、Java内存模型

3.2、Java内存模型带来的问题 

 3.3、同步协议

3.4、read/load操作示例

 4、保证变量可见性的方式

4.1、final变量

4.2、synchronized

4.2.1、synchronized语义规范

4.2.2、synchronized是如何做到线程安全的

4.3、volatile

4.3.1、volatile语义规范

4.3.2、volatile能做到线程安全吗

4.3.3、为何使用volatile

4.3.4、volatile的用途

4.3.5、volatile的使用场景


更多内容,请关注我的博客原文

1、并发中变量的可见性问题

在讲解线程安全的可见性问题前,先来解决几个简单的问题:

问题1:

变量分为哪几类?

全局变量有:

属性(静态的、非静态的)

局部变量有:

本地变量

参数

问题2:

如何在多线程下共享数据?

当然在问题1的答案下,我们知道多线程的数据共享可以使用全局变量(静态变量、共享对象)来解决。

问题3:

一个全局变量在线程1中被改变了,在线程2中能否看到该变量的最新值?

可能大多数人都会给出肯定的答案,既然是全局变量,便是所以线程共享的,线程1改了该变量的值,那么线程2肯定可以读到线程1修改后的值。为了颠覆这一认知,我们可以使用一个示例代码来看看:

代码逻辑:通过共享变量,在一个线程中控制另一个线程的执行流程

public class VolatileDemo{
    //全局共享变量,标识状态
    private static boolean is = true;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (VolatileDemo.is){
                    i++;
                }
                System.out.println(i);
            }
        }).start();

        try {
            //停止2秒种
            TimeUnit.SECONDS.sleep(2);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
        //设置is为false,使得上面的while线程结束循环
        VolatileDemo.is = false;
        System.out.println("被置为了false了。");
    }
}

按我们的设计思路,当is设置为false了后,while循环应该会结束,并打印i的值,并且打印出最终的i的值。但事实并非我们想想的那样,如果大家执行上面这个main方法后,,会发现程序一直没有结束while循环,并不会打印出i的值。

总结:

并发的线程能不能看见到变量的最新值,这就是并发中变量的可见性问题。

思考:

①、上述代码中主线程main对is变量的改变,为什么对子线程是不可见的?

②、怎样才能让主线程main对is的改变是对子线程是可见的?

2、怎样才能可见

要让并发中共享变量可见,可以使用synchronized或者volatile。

2.1、使用synchronized

我们使用synchronized同步关键字对第1节的代码做一个适当的调整:

public class VolatileDemo{
    //全局共享变量,标识状态
    private static boolean is = true;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (VolatileDemo.is){
                    synchronized (this){
                        i++;
                    }
                }
                System.out.println(i);
            }
        }).start();

        try {
            //停止2秒种
            TimeUnit.SECONDS.sleep(2);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
        //设置is为false,使得上面的while线程结束循环
        VolatileDemo.is = false;
        System.out.println("被置为了false了。");
    }
}

执行结果:

被置为了false了。

81147243

2.2、使用volatile

这里省略其他代码,除了is加上volatile关键字外,其他部分代码同第1节:

public class VolatileDemo {
    //全局共享变量,标识状态
    private static volatile boolean is = true;
    ...
}

 执行结果:

被置为了false了。

-512391385

i的结果为负的原因是因为int值溢出了。

思考:使用synchronized或者volatile为什么就可见了呢?

3、变量可见性、线程安全问题原因

3.1、Java内存模型

Java内存模型以及操作规范:

①、共享变量必须存放在主内存中;

②、线程有自己的工作内存,线程只可以操作自己的工作内存;

③、线程要操作共享变量,需要从主内存中读取到工作内存,改变值后需要从工作内存同步到主内存中。

 

3.2、Java内存模型带来的问题 

问题1:

有变量A,多线程并发对其累加会有什么问题?如果三个线程并发操作A,大家读取A时都读到A=0,都对A+1,再将值同步回主内存。结果时多少?

答案肯定是1,因为大家都读到0,最后都将A+1=1的结果同步到主内存中,所以结果肯定是1,这就是带来了线程安全以及可见性问题,它的本质也就是:

Java的内存模型是导致线程安全问题、可见性问题的根本原因

问题2:

那么如何让线程2使用A时看到最新值?

实现步骤:

①、线程1修改A后必须立马同步回主内存

②、线程2使用A时必须重新从主内存中读取到工作内存中

问题3:

那么实现了问题2的两个步骤,就一定能保证可见性?

 3.3、同步协议

java内存模型-同步交互协议,规定了8种原子操作:

①、lock(锁定):将主内存中的变量锁定,为一个线程独占

②、unclick(解锁):将lock加的锁定解除,此时其他线程可以有机会访问此变量

③、read(读取):作用于主内存变量,将主内存中的变量值读取到工作内存中

④、load(载入):作用于工作内存变量,将read读取的值保存到工作内存中的变量副本中

⑤、use(使用):作用于工作内存变量,将值传递给线程的代码执行引擎

⑥、assign(赋值):作用于工作内存变量,将执行引擎处理返回的值重新赋值给变量的副本

⑦、store(存储):作用于工作内存变量,将变量副本的值传送到主内存中

⑧、write(写入):作用于主内存变量,将store传送过来的值写入到主内存的共享变量中

将一个变量从主内存复制到工作内存中要顺序执行read、load操作;要将变量从工作内存同步回主内存要顺序执行store、write操作。只要求是顺序,没有要求一定是连续执行。

做了assign操作,必须同步回主内存,不能没有做assign,同步回主内存。

3.4、read/load操作示例

 4、保证变量可见性的方式

4.1、final变量

个人认为final修饰的变量是不可变的,一旦它被初始化,它的值不在可变,所以在任何时候,任何子线程中读取到它的值都是一致的,所以它在多线程操作下是可见的。

以下是《深入理解Java虚拟机》第二版的原话(可能不太好理解):

被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值

4.2、synchronized

4.2.1、synchronized语义规范

①、进入同步快前,先清空工作内存中的共享变量,从主内存中重新加载

②、解锁前必须把修改的共享变量同步回到主内存中

4.2.2、synchronized是如何做到线程安全的

①、锁机制保护共享资源,只有获得锁的线程才可以操作共享资源

②、synchronized语义规范保证了修改共享资源后,会同步到主内存,就做到了线程安全

虽然synchronized做到了以上两点,但是要实现共享变量的线程安全以及可见性的话,必须保证所有线程都竞争同一把锁,不能各自拿自己家的锁,然后各回各家。

4.3、volatile

4.3.1、volatile语义规范

①、使用volatile变量时,必须从主内存中加载,并且read、load是连续的

②、修改volatile变量后,必须立马同步回主内存,并且store、write是连续的

4.3.2、volatile能做到线程安全吗

不能,因为它没有锁机制,线程可以并发操作共享资源

我们可以举个例子:

public class AtomicityDemo {

    static volatile int count = 0;

    public static void increase(){
        count++;
    }

    public static void main(String[] args) {
        int threads = 20;
        CountDownLatch cdl = new CountDownLatch(threads);
        for (int i=0;i<threads;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0;i<10000;i++){
                        AtomicityDemo.increase();
                    }
                    cdl.countDown();
                }
            }).start();
        }
        try {
            cdl.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

上述代码可以看到,总共开了20个线程,每个线程对count变量加10000次,如果volatile能保证线程安全的话,结果应该是200000。下面是4次执行结果:

86669

104288

88572

85813

每次结果都不等于200000,可以看出volatile并不是线程安全的。

4.3.3、为何使用volatile

既然同步关键字synchronized能保证线程安全以及可见性,为何还需要使用volatile,原因如下:

①、主要原因:volatile比synchronized简单

②、volatile比synchronized性能要好,因为volatile没有加锁

③、synchronized并不是在所以情况下都能保证可见性,因为必须所以线程同时使用一把锁

④、volatile和synchronized同时使用的时候,可以适当的提高效率,比如懒汉式的单例模式(volatile的使用场景分析)

4.3.4、volatile的用途

volatile可用于限制局部代码指令的重排序:

4.3.5、volatile的使用场景

volatile的使用范围:

①、volatile只可以修饰成员变量(静态的、非静态的),因为只有成员变量才是所以线程共享的变量,而局部变量是线程独有,不存在可见性问题

②、多线程并发下,才需要使用它

volatile典型的应用场景:

①、只有一个修改者,多个使用者,要求保证可见性的场景

状态标识

数据定期发布,多个获取者

②、单例模式

懒汉式的单例模式

正确的懒汉式单例模式写法:

public class Singleton {

    //使用volatile
    private static volatile Singleton singleton;
    
    //私有化构造器
    private Singleton() {
    }

    public static Singleton getInstance(){
        //第一次检查
        if (singleton == null){
            synchronized(Singleton.class){
                //第二次检查
                if (singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

 两次检测的原因:

第一次检查很好理解,即先判断当前实例singleton 是否为null,如果不为null,则可以直接返回,避免了synchronized同步块竞争锁,影响效率;

进入synchronized同步块后第二次检查的原因是:假如有多个线程同时来获取singleton 实例,它们开始都得到的是null,然后都去竞争锁,但是一次只有一个线程能够获取到锁,当第一个线程创建好实例后出同步块,并更新了主内存的singleton实例,那么第二个线程在抢到锁进入同步块时,按synchronized的语法规则,它应该清理下工作区的共享变量,并重新获取共享变量,此时共享变量singleton不再为null,所以此时再次检查避免第二个线程又去创建实例,那样的话就不再是单例了。

那么既然两次检查同时能保证了可见性和线程安全问题,那为什么还需要volatile?

我们知道synchronized的可见性并不是很及时的,也就意味着它的store和write操作并非连续,中间可能会有其它原子操作,上面的例子中,我们假设在第一个线程刚刚好创建实例后,但还没有出同步块,这是主内存中的变量singleton还是null,这是突然又来个100个,甚至更多的线程来获取实例singleton,如果不使用volatile的话,它们得到singleton为null后,也会去synchronized并等待锁,当然问题也不大,等锁就等锁吧,就是效率相对有点低。那么有什么办法可以让这后来的100个线程不用等锁而直接return呢,那肯定想到的是volatile关键字,因为它的可见性是及时的,它的store和write操作是连续的,也就意味着第一个线程在创建完实例后,对其他线程是立即可见的,所有在后来100个线程进来后,可以直接拿到singleton实例,而不用去竞争锁,所有它某种意义下是提高了后来线程的效率。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值