无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。Java中首要的同步机制是synchronized关键字,它提供了独占锁。除此之外,术语“同步”还包括volatile变量,显示锁和原子变量的使用。
一个对象的状态就是它的数据
在没有正确同步的情况下,如果多个线程访问了同一个变量,你的程序就存在隐患。有3种方法修复它:

  • 不要跨线程共享变量;
  • 使状态变量为不可变的;或者
  • 在任何访问状态变量的时候使用同步。

    一、什么是线程安全性

    任何一个合理的“线程安全性”定义,其关键在于“正确性”的概念。如果我们关于线程安全性的定义是模糊的,那是因为缺少一个明确的“正确性”定义。
    一个类是线程安全的,是指在被多个线程访问时,类可以持续进行正确的行为。
    当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。

    1.1-示例:一个无状态(stateless)的servlet

    image.png
    StatelessFactorizer像大多数Servlet一样,是无状态的:它不包含域也没有引用其他类的域。一次特定计算的瞬时状态,会唯一地存在本地变量中,这些本地变量存储在线程的栈中,只有执行线程才能访问。一个访问statelessFactorizer的线程,不会影响访问同一个Servlect的其他线程的计算结果;因为两个线程不共享状态,它们如同在访问不同的实例。因为线程访问无状态对象的行为,不会影响其他线程访问该对象时的正确性,所以无状态对象是线程安全的。

    二、原子性

    我们向无状态对象中加入一个状态元素会怎样?假设我们想要添加“命中数( hitcounter)”来计算处理请求的数量。显而易见的方法是在Servlet中加入一个long类型的域,并在每个请求中递增它。如同清单2.2的UnsafeCountingFactorizer所示。
    Servlet计算请求数量而没有必要的同步(不要这样做)
    image.png
    很遗憾,UnsafeCountingFactorizer并非线程安全的,尽管它在单线程的环境中运行良好。正如第6页中的Unsafesequence,它很容易遗失更新(lost updates)。自增操作++count 由于其紧凑的语法格式,看上去更像一个单独的操作。然而,它不是原子操作。
    假设计数器的初始值为9,在某些特殊的分时里,每个线程都将读它的值,并看到值是9,然后同时加1,最后都将counter 设置为10。很明显,这不是我们期望发生的事情:一次递增操作凭空消失了,一次命中计数被永久地取消了。

    2.1 竞争条件

    在一些偶发时段里,出现错误结果的可能性对于并发程序而言非常重要,以致于专门用-个名词来描述它们:竞争条件。UnsafecountingFactorizer中存在数个竞争条件,导致其结果是不可靠的。

    2.2 示例:惰性初始化中的竞争条件

    image.png
    LazyInitRace 中的竞争条件会破坏其正确性。比如说线程A和B同时执行getInstance,A看到instance 是null,并实例化一个新的ExpensiveObject。同时B也在检查 instance是否为null。此时此刻的instance是否为null,这依赖于时序,这是无法预期的。它包括调度的无常性,以及A初始化ExpensiveObject并设置instance域的耗时。如果B检查到instance为null,两个getInstance的调用者会得到不同的结果。然而,我们期望getInstance总是返回相同的实例。

    2.3 复合操作

    假设有操作A和B,如果从执行A的线程的角度看,当其他线程执行B时,要么B全部执行完成,要么一点都没有执行,这样A和B互为原子操作。一个原子操作是指:该操作对于所有的操作,包括它自己,都满足前面描述的状态。
    为了确保线程安全,“检查再运行”操作(如惰性初始化)和读-改-写操作(如自增)必须是原子操作。我们将“检查再运行”和读-改-写操作的全部执行过程看作是复合操作:为了保证线程安全,操作必须原子地执行。
    image.png
    java.util.concurrent.atomic包中包括了原子变量( atomic variable)类,这些类用来实现数字和对象引用的原子状态转换。把long类型的计数器替换为AtomicLong类型的,我们可以确保所有访问计数器状态的操作都是原子的5。计数器是线程安全的了,而计数器的状态就是Servlet的状态,所以我们的Servlet再次成为线程安全的了。

    三、锁

    image.png
    很不幸,这种方法并不正确。尽管原子引用((atomic references)自身是线程安全的,不过UnsafeCachingFactorizer中存在竞争条件,导致它会产生错误的答案。
    线程安全性的定义要求无论是多线程中的时序或交替操作,都要保证不破坏那些不变约束。UnsafeCachingFactorizer的一个不变约束是:缓存在lastFactors中的各个因子的乘积应该等于缓存在lastNumber中的数值。只有遵守这个不变约束,我们的Servlet才是正确的。
    当线程A尝试获取两个值的时间里,线程B可能已经修改了它们,线程A过后会观察到Servlet违反了不变约束。

    3.1-内部锁

    Java提供了强制原子性的内置锁机制:synchronized块。
    一个synchronized块有两部分:
  1. 锁对象的引用
  2. 锁保护的代码块

内部锁在Java中扮演了互斥锁(mutual exclusion lock,也称作mutex)的角色,意味着至多只有一个线程可以拥有锁,当线程A尝试请求一个被线程B占有的锁时,线程A必须等待或者阻塞,直到B释放它。如果B永远不释放锁,A将永远等下去。

现在synchronizedFactorizer又是线程安全的了;但是这种方法过于极端,它完全禁止多个用户同时使用factoring servlet——这导致糟糕的、令人无法接受的响应性。
image.png

3.2-重进入(Reentrancy)

当一个线程请求其他线程已经占有的锁时,请求线程将被阻塞。然而内部锁是可重进入的,因此线程在试图获得它自己占有的锁时,请求会成功。重进入意味着所的请求是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的’。重进入的实现是通过为每个锁关联一个请求计数(acquisition count)和一个占有它的线程。当计数为0时,认为锁是未被占有的。线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数置为1。如果同一线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器达到0时,锁被释放。

重进入方便了锁行为的封装,因此简化了面向对象并发代码的开发。清单2.7中,子类覆写了父类synchronized类型的方法,并调用父类中的方法。如果没有可重入的锁,这段看上去很自然的代码就会产生死锁。因为 widget和 Loggingwidget中的doSomething方法都是synchronized类型的,都会在处理前试图获得widget的锁。倘若内部锁不是可重入的,super. doSomething的调用者就永远无法得到widget的锁,因为锁已经被占有,导致线程会永久地延迟,等待着一个永远无法获得的锁。重进入帮助我们避免了这种死锁。
image.png

四、用锁来保护状态

因为锁使得线程能够串行地(serialized8)访问它所保护的代码路径,所以我们可以用锁来创建相关的协议,以保证线程对共享状态的独占访问。只要始终如一地遵循这些协议,就能够确保状态的一致性。

对于每个可被多个线程访问的可变状态变量,如果所有访问它的线程在执行时都占有同一个锁,这种情况下,我们称这个变量是由这个锁保护的。
一种常见的锁规则是在对象内部封装所有的可变状态,通过对象的内部锁来同步任何访问可变状态的代码路径,保护它在并发访问中的安全。很多的线程安全类都是这个模式,例如Vector和其他同步的容器(collection)类。
对于每一个涉及多个变量的不变约束,需要同一个锁保护其所有的变量。

五、活跃度与性能

image.png
重新构造后的cachedFactorizer提供了简单性(同步整个方法)与并发性(同步尽可能短的代码路径)之间的平衡。请求与释放锁的操作需要开销,所以将synchronized块分解得过于琐碎(比如将++hit分解到它自身的synchronized块中)是不合理的,即使这样做是为了获得更好的原子性。当访问状态变量或者执行复合操作期间,CachedFactorizer会占有锁,但是执行潜在耗时的因数分解之前,它会释放锁。这样既保护了线程安全性,也不会过多地影响并发性;每个synchronized块的代码路径已经“足够短”了。