Java并发编程实战 - 学习笔记

1 篇文章 0 订阅
1 篇文章 0 订阅

第2章 线程安全性

1. 基本概念

什么是线程安全性?可以这样理解:一个类在多线程环境下,无论运行时环境怎样调度,无论多个线程之间的执行顺序是什么,且在主调代码中不需要进行任何额外的同步,如果该类都能呈现出预期的、正确的行为,那么该类就是线程安全的。
既然这样,那么完全由线程安全类组成的程序,就一定是线程安全的程序吗?也不见得。而且线程安全类中也可以包含非线程安全的类。
线程安全性虽然是一个代码上使用的术语,但它只是与状态相关的,因此只能应用于封装了这个(些)状态的整个代码(不能再多),它可能是一个对象,也可能是整个程序。因此,只有当类中仅包含自己的状态时,线程安全类才是有意义的。

2. 相关术语

竞态条件(Race Condition):在并发编程中,由于不恰当的执行时序而导致错误的结果。
竞态条件的典型案例:

  • 先检查后执行(Check-Then-Act),如
if (instance == null)
    instance = new Singleton();
  • 读取-修改-写入,如
counter++; // 虽然看上去很像原子操作

原子操作:两个操作A和B,如果从执行A的线程来看,当另一个线程执行操作B时,要么将B执行完,要么完全没有执行,那么B对A来说就是原子的。(书上说这时A和B对彼此来说都是原子的,really?)
像上面说的“先检查后执行”以及“读取-修改-写入”都是复合操作,需要保证操作的原子性以确保线程安全。
原子变量类(java.util.concurrent.atomic包下的,如AtomicLong)对外提供的操作都是原子的,因此这些类也是线程安全的。
当在无状态的类中添加一个状态,并且该状态完全由线程安全的对象来管理,那么该类仍是线程安全的。但是当状态由一个变成多个时,即使每个状态都由线程安全类管理,该类也不见得是线程安全的。多个状态之间可能需要满足一定的不变性条件,要在原子操作中对涉及不变性条件的所有状态进行更新,使它们始终维持不变性条件,才能保证线程安全。

3. 用锁来保护状态

如果使用同步来协调对某个变量的访问,那么对该对象的的有访问都要使用同步(并不是只有写操作才需要同步)。如果是基于锁的同步,那么对该变量的所有同步都要使用同一个锁。当类的不变性条件涉及多个变量时,那么这些变量都要使用同一个锁来保护。

4. 活跃性与性能

当使用锁时,应该清楚代码块所实现的功能,以及该代码块是否含有耗时较长的操作(如密集型计算或是阻塞操作),如果持有锁的时间较长,则可能会带来活跃性与性能的问题。
通常,要判断同步代码块的合理大小,我们要在多个设计需求之间进行权衡,包括安全性(这个必须被满足)、简单性(如对整个方法加synchronized进行同步)、并发性(即性能)。
有时候,在简单性和性能之间会发生冲突。粗暴地将整个方法或是一大片代码块加锁,虽然简单,但可能会影响并发性;而如果将同步代码块分得过细(例如将一个cnt++操作放在它自己的同步代码块中),性能也不见得会好,因为获取和释放锁都需要一定的开销。另外,一味地为了性能而牺牲简单性,可能也会破坏安全性。尽管如此,在简单性和性能之间,一般也能找到某种合理的平衡。

第3章 对象的共享

1. 可见性

在没有同步的情况下,编译器、处理器和运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。但有一种简单的方法可以解决这个问题:只要有数据会被多个线程共享,就使用正确的同步。
一个只有单个状态的类(状态私有,通过getter和setter被外界访问),如果对该状态的get和set方法都加synchronized进行同步,则可以实现这个类的线程安全。不能只对set方法进行同步,调用get的线程仍然可能看见失效值。
对没有同步的状态进行多线程的访问,可能会得到一个失效值,但这个值至少是之前某个线程设置的值,而不是一个随机值,这种安全性保证也被称为最低安全性(out-of-thin-air-safety)。最低安全性适用于绝大多数变量,但是有一个例外:非volatile类型64位数值变量(long和double)。Java内存模型要求对变量的读取和写入操作都是原子操作,但对于非volatile类型的double和long变量,JVM允许对变量的高32位和低32位执行分开的读或写操作。因此如果读和写在不同的线程中进行,线程读取的一个变量值,可能是由某个值的低32位和另一个值的高32位组成,这是一个没有意义的值。可以用volatile修饰,或是加锁来解决这个问题。
内置锁可以用于确保某个线程以一种可以预测的方式来查看另一个线程的执行结果。比如线程A和B,线程A先执行一段同步代码块,线程B接着执行由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被线程A释放之前,线程A执行的所有操作结果,在线程B获取锁之后都可以看到。如果没有同步,就无法实现上述保证。因此加锁的含义不仅仅是互斥,还是为了内存可见性。为了确保所有线程都能看见共享变量的最新值,所有执行读或写操作的线程都必须使用同一个锁来进行同步。
volatile关键字也可以实现类似的可见性效果。当一个变量被volatile修饰时,编译器和运行时都会知道这个变量是共享的,因此不会将该变量的相关操作和其它内存操作一起重排序。volatile变量不会被缓存在寄存器或是对其它处理器不可见的地方,因此对变量的读取总是能获取到最新写入的值。
volatile变量对可见性的影响要比volatile变量本身更加重要,因为它可以影响到其它变量的可见性。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对线程A可见的所有变量值,在线程B读取volatile变量后对线程B都是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,读取volatile变量相当于进入同步代码块。
volatile变量的正确使用方式包括:确保它们自身状态的可见性,以及它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(如初始化和关闭)。
尽管如此,volatile的语义并不能保证操作的原子性(如count++),除非你能确保只有一个线程会对该变量进行写操作。并不建议过度依赖volatile所提供的可见性,它通常比使用锁的代码更加脆弱,也更难以理解。
当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖对象的当前值,或者只有一个线程会对变量进行写操作。
  • 该变量不会与其它变量一起被纳入不变性条件之中。
  • 在访问该变量时不需要加锁。

2. 发布与逸出

发布(publish)一个对象是指,使得该对象能够在当前作用域之外的代码中使用。当某个不该发布的对象被发布时,这种情况就叫做逸出(escape)
发布一个对象有以下几种方式:

  • 最简单的方法是把对象的引用保存到一个公有的静态变量中。
  • 当发布某个对象时,可能会间接发布其它对象。如发布一个集合,则集合的元素也被发布。
  • 发布一个对象时,该对象非私有域所引用的对象也被发布。
  • 当把一个对象传递给外部方法时,也就相当于发布了该对象,因为你无法假设外部方法会对该对象做些什么操作。对一个类C,外部(alien)方法指行为并不完全由类C所规定的方法,包括其它类中定义的方法以及类C中可以被改写的方法(既不是private也不是final的)。
  • 发布一个内部的类实例,会导致其外层的对象被发布。如this引用在构造函数中逸出(即使发布对象的语句位于构造函数的最后一行),那么这种对象就被认为是不正确构造。因为当且仅当对象的构造方法返回时,对象才处于可预测的和一致的状态。
    常见的类似错误是在构造函数中启动一个线程。当在构造函数中创建一个线程,无论是显式创建(将它传给(线程的?)构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),对象都会被线程所引用,这本来OK,但最好不要在构造方法中启动它。
    在构造方法中调用一个可改写的实例方法时,同样会导致this引用在构造过程中逸出。
    如果想在构造方法中注册一个监听器或是启动线程,可以用一个私有的构造方法和一个公共的工厂方法来搞定。

3. 线程封闭

将数据限定仅在单线程中访问,就是线程封闭,这是实现线程安全性的最简单方式之一。Swing将可视化组件和数据模型对象(它们不是线程安全的)封装在事件分发线程中,JDBC的Connection连接池都利用了线程封闭。

  • Ad-hoc线程封闭:指维护线程封闭性的职责完全由程序实现来承担,这往往是比较脆弱的。volatile类型的变量有一种特殊的封闭,即如果能确保只有一个线程对共享的volatile变量进行写操作,则可以对该变量进行安全的读取-修改-写入操作,因为互斥性和可见性都得到满足。
  • 栈封闭:即只能通过局部变量访问对象。基本类型的局部变量,一定不会逸出;引用类型的局部变量则有逸出的可能。
  • ThreadLocal类。常用于防止对可变的单例变量或全局变量进行共享,如数据库的Connection对象,一般会在程序启动时初始化它并保存,从而避免以后每次调数据库方法时都要传一个Connection对象,由于它不是线程安全的,因此可以保存到ThreadLocal对象中,从而防止多线程同时访问这种全局变量时可能出现的线程安全问题。

4. 不变性

如果一个对象在创建之后,其状态就不能修改,那么这个对象就是不可变的。不可变对象与不可变的对象引用是不同的。
当满足以下所有条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域都是final类型。
  • 对象是正确创建的(即在创建期间,this引用没有逸出)。
    对于在访问和更新多个相关可变变量时存在的竞争条件问题,可以通过将所有相关变量保存在一个不可变对象中来解决。

5. 安全发布

要安全地发布一个对象,对象的引用和对象的状态必须同时对其它线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。因为静态初始化器在由JVM在类的初始化阶段执行,JVM内部存在同步机制。
  • 将对象的引用保存到volatile类型的域或者AtomicReference对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。
  • 通过线程安全容器来发布

此外,对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值