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

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Fun_Woody/article/details/84173372

第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. 发布与逸出

猜你喜欢

转载自blog.csdn.net/Fun_Woody/article/details/84173372
今日推荐