02.线程安全性问题

[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并发编程实战

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

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,018评论 11 349
  • 第2章 java并发机制的底层实现原理 Java中所使用的并发机制依赖于JVM的实现和CPU的指令。 2.1 vo...
    kennethan阅读 1,291评论 0 2
  • 目录: 1. 指令重排 2. 顺序一致性 3. volatile 4. final 1.指令重排 要了解指令重排,...
    西部小笼包阅读 715评论 0 1
  • 文章原地址 一、添加版本资源库 点击Cornerstone左下角REPOSITORIES栏右边的加号按钮,在弹出的...
    猪猪侠在这阅读 716评论 0 0
  • 鱼儿 作者: 大吕 鱼儿缸中游, 犹觉不自由。 夜半急跃起, 朝闻则槁矣。 我家的鱼缸里原本有三只小鱼儿,...
    大吕娘阅读 144评论 0 0