Java线程安全杂谈(上)——线程安全概念和基本方案

前面已经说了并发的线程相关的基本内容,但这也仅仅是个基本内容的整理。线程安全问题绝对是并发开发中一个重点中的重点,这篇就来说说线程安全相关的一些问题。线程安全是什么概念?这个概念说简单也简单,说复杂也复杂,“安全”的概念是什么,用我个人的话说的浅显些,就是类/对象本身在多线程并发执行的场景下,能够保证程序的逻辑是可以接受的而不是被扰乱的,保证业务处理不出问题,这个定义并不标准,但线程安全的实际概念确实很难简单而又准确地表达,我们从下面的故事说起。

我们前面也提到了一些“主线程”“新线程”,当然我们也会用到线程池里的线程来完成任务。那么,如果这些任务都“各司其职”“互不干涉”,那自然是一切安好。但这个条件貌似太苛刻了,在很多场景下,为了更好完成很多任务,各个线程必然需要相互合作,共享一些数据,比如一个Java程序运行,在堆内存中有一块对象O,当前线程P创建了这个对象并持有这个对象的引用,这时候我们需要充分利用CPU资源开几个额外的线程来处理任务,并把对象O传给他们,比如有两个线程A和B。那现在在线程P,线程A和B中都持有堆中唯一对象O的引用,也就都可以对O进行操作。那是不是这就会有问题了呢?也不一定,如果对象O的引用只在这3个线程中持有,而且这三个线程对对象O的操作都是只读的,也就是说O自从创建起就没在变过,那么可以想象也不会有什么问题存在。但如果有线程会改变对象O,问题就不这么简单了。

1. 问题所在。继续说上面的故事,让我们更具体化一些,对象O中有一个A和B线程都可见的int变量a,当前初始值是0。现在A和B同时都尝试对O.a进行加1的操作,A线程去读得到0,于是加1变成1,但还没来得急写回去,B也做了同样的操作也以为应该把1写回去。那么A和B线程把0写成1这个过程是对的么?如果P线程创建A和B线程的目的就是最终让0变成1,那么没问题,但如果我们的目的是让A和B分别帮助对象O“成长”,让a成为A线程和B线程某操作次数的记录,那显然,向上述的过程,如果A和B都把1写会去,a就漏掉了一次。这是一个“read-and-update”的过程(也有的叫做“read-modify-write”)。

刚刚那个例子是读取然后就进行更新写回,现在看另外一个情况。前面描述的共享对象O除了上面情况下用到的属性a,现在还有一个java.lang.Object类型的属性b,初始情况下b的引用为null。现在的需求是对b做Lazy Init,第一个访问到b的线程负责创建和初始化,而后续不允许再发生变化,这个和单例模式对象的实现有类似之处。简单来说就是如下的代码:

if ( O.b == null )
{
    O.b = new Object();
}

和上面一样,现在A和B共享对象O,并同时使用到b属性,会不会有问题?按照刚刚描述的需求必然会,因为破坏了第二个要求引用“不允许在发生变化”。当A先发现属性b为空的时候,创建一个对象Object1给b,而创建这个对象是需要一系列操作的,还没有创建完成b引用依旧为null的时候,B线程来了,于是也进入了if代码块,又创建了一个对象Object2,则b会先被赋予Object1,而后Object2 。这个例子中Object对象的创建仅仅是一个代表,其实实际的操作可以更复杂,操作的结果会使if的条件不成立。这又是一类情况,叫“check then act”

以上两个例子是我写这篇整理的时候临时编出来的,不知道是否完全合适,但read and update和check then act却囊括了线程安全问题中的很多竞争情况。

2. 解决简单问题的一个方案——保证原子性。从上面的情况可以看到,之所以出现了问题是因为read和update之间以及check和act的操作持续足够久,而且中间允许别的线程插进来进行操作,也就是可以理解为我们做了一系列操作,每个操作之间给别的线程留有余地。那么如果我们保证这一系列操作“足够快”“足够小”,那么就不会给别的线程可入之机。那一个方案就是,操作的原子化。JavaSE5之后,java.util.concurrent.atomic包中给出了丰富的原子数据类,提供了原子保证的操作方法。在Sun JDK中,这些都是基于sun.misc.Unsafe类实现的。需要注意的是,是没有对应于Float和Double的原子类的。

3. 对于略为复杂的情况,原子类并不能解决问题,这时候往往需要锁来解决。所谓“复杂”的情况有很多,较为常见的一种情况就是,如果一个操作需要涉及到两个变量,而这两个变量又不能同时被一个原子类保证,那我们所能做的恐怕就只有考虑加锁使用。加锁保证了一系列操作的原子性,保证了被锁对象的“不可见性”和顺序性,关于这三个特性更多的讨论,我们放在后边。

4. 到锁为止,上面的一个故事基本上算是讲完了,但实际情况中考虑的问题可能不仅仅是这么简单。我们现在回头看下线程安全的更准确描述和设计中的考虑。《Java Concurrency in Practice》书中给了一个描述更为准确也比较好理解的定义,就是“如果一个类在多线程执行中,在不考虑运行环境的调度干预,也不需要调用代码的协调同步,仍然保证正确地运行,那么这个类就是线程安全的”。按照这个定义,这本书的作者Brian Goetz在其文章中又进一步把线程安全分了5个级别:

  • 不可变的,这个没什么可说的
  • 绝对安全/无条件的线程安全,通常来讲这个类“怎么用怎么安全”,但我加了双引号,其实还是要注意的
  • 相对安全/有条件的,方法单个使用是没问题的,但为了处理某些业务按照顺序连续调用需要同步
  • 线程兼容/非线程安全,需要外部同步保证
  • 线程对立,没法协调做到安全

更具体的,要看原文描述了。IBM developerworks上翻译成中文的文章:

http://www.ibm.com/developerworks/cn/java/j-jtp09263/

其它关于线程安全方面在设计实践中需要的思考我简要整理了下:

  • 对于一个类,能做到不可变的就做到不可变,能做到不被其它类引用到就不公开出来
  • 能只读的就只读,保证安全
  • 需要被公开使用的要注意被公开场景对象状态的正确性,还要保证同时其它对象的公开性,尤其在使用容器类的时候
  • 能够用临时变量做操作的就用方法内的临时变量,而不用类的属性,保证是线程内存在的,用栈中数据而非堆中共享的,保证类的无状态性
  • 用好ThreadLocal类
  • 做好逻辑上的需求分析。通常需要考虑状态转换的问题,比如从状态1不能直接到状态3,必需经过状态2;还有就是多状态的约束,比如一个三位的二进制状态,不是8种全有,而只有101、110、111、000是合法的,那么要维护好三个状态位。同时,还要搞清楚状态的所有者是谁,维护好状态所有者
  • 用好原子类和锁

……

先整理这些,下文考虑会接着整理Java内存模型、执行顺序以及volatile和三特性(原子/可见/有序)等。

此条目发表在 Java, Java语言, 并发, 开发, 计算机技术 分类目录,贴了 , , , , , 标签。将固定链接加入收藏夹。

Java线程安全杂谈(上)——线程安全概念和基本方案》有 2 条评论

  1. iq527 说:

    read-and-update,感觉和check-then-act可以归一类的啊?

    • 三石 说:

      还是不太一样的。read and update是同一个东西发生了变化,而后者并不是这样。可能我描述得不大好,这个整理总结是读《Java Concurrency in Practice》得到的。

发表评论

电子邮件地址不会被公开。 必填项已用 * 标注

您可以使用这些 HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>