美文网首页
02.线程安全性问题

02.线程安全性问题

作者: 0x70e8 | 来源:发表于2018-08-06 11:04 被阅读0次

    [TOC]

    安全性问题概述

    什么是安全性问题

    多线程情况下的安全问题,是指数据的一致性问题,在多线程环境下,多个线程能无序地读写可变变量,在无法确定的线程读写顺序的情况下,可能会表现出数据不一致的现象。程序想要正确的执行,是不能有这种随机性存在的,这是不安全的也是不符合设计的。

    线程安全的定义

    简单说,线程安全就是在多线程同时访问一个类的环境下,无论线程的访问顺序是怎样的,这个类始终能表现出一致的正确的行为,那么就可以说这个类是线程安全的。

    原因

    为什么会出现线程安全性问题?

    • 首先,自然是因为多线程的无序性特征,一个线程可以在另一个线程的执行步骤的中间步骤交错执行(单线程就不会有线程安全问题)
    • 其次在于多线程共享某个可变的状态变量(状态也就是类的属性或者对象的属性),如果不共享,则各自独立,或者如果共享的状态是不可改变的,那么这个状态会保持始终一致;
    • 再者由于计算机内存模型的缓存机制,使得线程会在缓存空间保存共享变量的副本,而对副本的修改没有及时刷新到主存以及主存的变化没有及时更新到缓存中,导致变量的变化无法及时对其他线程可见。
    • 最后是处理器和虚拟机针对指令重排序的优化策略,在单线程情况下不会造成结果错误,但在多线程情况下,无形中加重了无序性的危害;

    同步策略

    同步策略定义了如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同。

    同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁保护。

    对应上面列出的造成线程安全性的原因,需要构造一致性的协议,可以有以下的对策:

    • 不使用多线程(这不现实)
    • 不共享(线程封闭)
    • 可以共享,但是共享的变量不可变(不可变性)
    • 使用加锁机制来保证可见性和有序性以及原子性

    实际上大部分情况下,都需要使用同步机制来保证线程安全性。所以同步机制是Java并发中最主要的部分。

    1.状态不共享(线程封闭)

    一个线程不能访问其他线程的工作内存,变量如果是线程私有的,就不会有线程安全问题。这种技术是实现线程安全最简单的方式之一,叫做线程封闭。原理就是讲对象封闭在线程内部,不与其他线程共享。如JDBC的连接池,每一个连接对象只能分配给一个线程,相当于connection对象在使用期间,被封闭在单个线程中。

    • 栈封闭: 实际上就是局部变量,封闭在线程的私有内存线程的栈中,其他线程无法访问(思考栈和栈帧);
    • ThreadLocal类:将变量和线程绑定,通常用于防止对可变的单实例变量或全局变量进行共享。这些绑定到线程的值保存在Thread对象中,线程结束后,垃圾收集器会回收它们。
    • 实例封闭

    把数据封装在对象的内部,限制对数据的修改方法,即使这个状态对象时线程不安全的类,也能保证封装此对象的类是线程安全的类。
    考虑下面这个类:

    public class MySet{
        // HashSet 不是线程安全的类
        private final Set<Integer> state = new HashSet<>();
        
        public synchronized void addState(Integer e){
            state.add(e);
        }
        
        public synchronized boolean containsState(Integer e){
            return state.contains(e);
        }
        // 此外没有涉及state的其他方法
    }
    

    这个示例就是把非线程的类对象封装在对象内部,多线程共享一个MySet类时,并不能并发的去修改内部的非线程安全的状态,所以MySet是线程安全的类。
    Java类库提供的类似Collections.synchroizedList()来包装ArrayList对象,实际上就是实例封闭的使用。包装器对象拥有对底层容器对象的唯一引用,不会将对象引用发布出去,另外对非线程安全的底层容器的访问,都将方法封装成同步方法。

    2.状态不可变

    • 使用不可变对象

    对象时不可变的的条件

    • 对象创建以后状态不可修改
    • 对象所有域都是final域(不要求域是不可变对象,只要封装在类中不会被外界修改就可以,如String类)
    • 对象时正确构建的(构造期间没有this逃逸)

    3.加锁机制

    加锁机制是将对象的所有可变状态封装起来,使用锁来保护。要想获得访问权,需要先获得锁。

    从以下几个方面来保护数据:

    可见性

    当一个线程修改了一个共享变量,这个操作结果能立即被其他的线程接收到,就是此变量是立即可见的。

    为什么会有可见性问题,是因为在计算机的结构中,CPU是要依赖内存(RAM)的,但是CPU寄存器和主存的读写速度差距非常大,内存读写速度成为计算性能的瓶颈,于是有了高速缓存来缓解这种差距,即把常用的(可能会用到的数据)存到高速缓存里(这其中有一个缓存命中的机制),所以就有了CPU-缓存-主存这样的数据读写模型。对于多核处理器,他们共享主存,但是各自都有各自的缓存,缓存的数据来自主存,但是处理器计算的结果是需要从缓存刷新到主存中的,如果同时更改,以谁的结果为准呢,所以在缓存一致性上需要多个处理器达成协议。

    在Java多线程中,多个线程共享主存,但是线程自身拥有工作内存,相当于CPU的高速缓存,它拷贝了主存的变量副本进行本地计算,计算结果刷新到主存,但是这个刷新操作,并不保证是立即刷新的,如果每个变量都可以立即刷新到主存中(还有有一致性协议),那就不存在可见性问题了,但是这是需要很大的开销的。所以不会每个变量都刷新,也就造成了可见性问题。

    JMM中的内存交互(主存和线程工作内存的交互)

    变量在主存和工作内存的之间的往返,JMM定义了八种操作

    1. lock --> 作用于主存变量,将变量标为某条线程独占状态,且会将工作内存此变量的副本清空,使得读取此变量必须从主存获取
    2. unlock --> 作用于主存变量,解除线程独占状态,在unlock之前会把工作内存中的此变量最新值更新到主存中,unlock之后的变量可以被其他线程锁定
    3. read --> 将主存变量读入工作内存
    4. load —-> 将read操作读入工作内存的变量载入工作内存的变量副本中
    5. use --> 把工作内存的变量传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时会执行这个操作
    6. assign --> 把从执行引擎接收到的值赋给工作内存中的变量,就是变量赋值
    7. store --> 把工作内存的一个变量的值传送到主存中
    8. write --> 把store操作的来自工作内存的变量值写入主存的变量中

    这8个操作虚拟机会保证他们是原子的,并且read、load和store、write必须是成对出现的。

    lock和unlock操作实际上JVM并没有开放给用户使用,是内部使用的操作,JVM提供了更高层次的的字节码指令monitorenter和monitorexit来隐式使用lock和unlock,这两个字节码对应的就是synchronized关键字,因此synchronized块或方法也具有原子性

    在Java同步机制里,保证可见性的实现依靠底层处理器的指令,通过这些指令可以强制刷新缓存到主存,以及强制刷新主存值到缓存,这样也就保证了可见性。

    在Java语言层面,锁机制(就是上面的lock和unlock操作)和volatile关键字(实际上是一种轻量级的锁机制)还有final关键字可以实现这种需求。
    当把一个变量声明为volatile类型后,编译器和运行时会注意到它是一个共享变量,volatile变量不会缓存到寄存器或对其他线程不可见的地方,读其的读取总是最新的,所以写操作也是立即对其他线程可见。

    final的可见性指的是,被final修饰的字段在构造函数中一旦初始化完成,并且构造器没有把this逸出,那么其他线程就能立即看到final字段的值。

    有序性

    • 线程串行化执行复合操作-加锁机制

    加锁是一种独占的机制,线程想要访问受锁保护的区域,必须先获取锁,且同一时刻只有一个线程可以持有锁,没有获取锁的只能等待锁释放,然后去竞争这个锁,这和线程竞争CPU时间片相似。这样使得多线程对加锁块的访问变成串行操作,当然具体线程的顺序还是不定的。

    • 禁止指令重排序-volatile/锁

    JMM定义的禁止处理器和JVM进行指令重排序的规则如下:

    是否能重排序 第二个操作 - -
    第一个操作 普通读/写 volatile读 volatile写
    普通读/写 NO
    volatile读 NO NO NO
    volatile写 NO NO

    monitorEnter和volatile读规则一致,monitorExit和volatile写的规则一致。底层实现是使用内存屏障。
    对于以上的禁止重排序的规则,JSR-133指出这些只是应用于多线程访问变量的情况下。如果编译器可以证明一个锁只会被一个线程访问,那么它会忽略这个锁,也就是依然会重排序,这是编译器层面的优化,不会影响程序的正确性。
    对于上述的允许重排序的操作,重排序是基于数据之间没有依赖关系为了更高性能的优化策略,首要保证的是单线程下的正确性。

    另外,final关键字也有禁止指令重排序的规则:

    1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个变量,这两个操作之间不能重排序。
    2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

    原子性

    • 互斥锁
    • 原子变量类

    一个原子操作是指操作的步骤不可拆分,比如一条机器指令的执行,没有可见的中间步骤。实际上有些原子操作,是通过语言层的封装,使得其内部的中间步骤,在整个操作完成之前,对其他的操作不可见或者不可访问。互斥锁就是这样的一种实现。

    Java的锁机制可以将一系列操作加锁,从而封装成一个语言层的原子操作。原理即为一个锁只能被一个线程持有,在有线程持有锁的期间,另外一个线程是无法去访问加锁的代码段的。这既使得对临界区的代码访问变成线程串行化,也是的操作原子化。

    volatile不能保证原子性,只能保证可见性和有序。
    原子变量是一种更好的volatile,能够支持原子的和有条件的读-写操作。是一种更加细粒度和轻量级的锁机制。

    从上面的结论可以发现,锁可以提供可见性、有序性和原子性,volatile可以提供可见性和有序性(禁止指令重排序实际上可以看做是可见性部分),final关键字可以提供可见性已经有序性(禁止指令重排序)。

    同步机制是解决多线程安全问题的主要方式,锁是使用最多的一个。

    正确地同步

    JSR-133中内容:

    A program must be correctly synchronized to avoid the kinds of counterintuitive behaviors that can be observed when code is reordered. The use of correct synchronization does not ensure that the overall behavior of a program is correct. However, its use does allow a programmer to reason about the possible behaviors of a program in a simple way; the behavior of a correctly synchronized program is much less dependent on possible reorderings. Without correct synchronization, very strange, confusing and counterintuitive behaviors are possible.

    程序必须正确同步,以避免代码重新排序时可能出现的违反直觉的行为。 使用正确的同步不能确保程序的整体行为是正确的。 然而,它的使用确实允许程序员以简单的方式推断程序的可能行为; 正确同步的程序的行为更少依赖于可能的重新排序。 没有正确的同步,非常奇怪、混乱和违反直觉的行为是可能出现的。

    There are two key ideas to understanding whether a program is correctly synchronized:

    1. Conflicting Accesses Two accesses (reads of or writes to) the same shared field or array element
      are said to be conflicting if at least one of the accesses is a write.

    2. Happens-Before Relationship Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second. It should be stressed that a happens-before relationship between two actions does not imply that those actions must occur in that order in a Java platform implementation. The happens-before relation mostly stresses orderings between two actions that conflict with each other, and defines when data races take place.(应该强调的是,两个操作之间发生的事前关系并不意味着这些操作必须按照Java平台实现中的顺序进行。 发生之前的关系主要强调两个彼此冲突的动作之间的排序,并定义数据竞争何时发生。)

    There are a number of ways to induce a happens-before ordering, including:

    • Each action in a thread happens-before every subsequent action in that thread.
    • An unlock on a monitor happens-before every subsequent lock on that monitor.
    • A write to a volatile field happens-before every subsequent read of that volatile.
    • A call to start() on a thread happens-before any actions in the started thread.
    • All actions in a thread happen-before any other thread successfully returns from a join() on that thread.
    • If an action a happens-before an action b, and b happens before an action c, then a happens- before c.

    Happends-before不是时间上的先发生,而是时间上先发生的造成的影响能能对后发生的可见。

    参考资料

    [1] Java并发编程实战

    相关文章

      网友评论

          本文标题:02.线程安全性问题

          本文链接:https://www.haomeiwen.com/subject/zmywvftx.html