思维导图:

0. 前言

线程或者锁在并发编程中的作用,类似于铆钉与工字梁在土木工程中的作用。构建稳健的并发程序,必须正确的使用线程和锁。其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问

从非正式的意义上说,对象的状态指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他
依赖对象的域。例如,某个hashmap的状态不仅存储在对象本身,还存储在许多map.entry对象中。“共享” 意味着变量可以有多个线程同时访问,而 “可变” 则意味着变量的值在生命周期可以发生变化。
复制代码

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协调这些线程对变量的访问。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:* 不在线程之间共享该状态变量
* 将状态变量修改为不可变的变量
* 在访问状态变量时使用同步
复制代码

1. 什么是线程安全性

在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是,某个类的行为与其规范完全一致。因此就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

示例:一个无状态的servlet 程序2-1给出了一个简单的因数分解servlet。这个servlet从请求中提取出数值,执行因数分解,然后将结果封装到servlet的响应中。

与大多数servlet相同,StatelessFactorizer无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。 由于线程访问无状态对象的行为并不会影响其他线程操作的正确性,因此无状态对象是线程安全的。

2. 原子性

当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们想增加一个“命中计数器”来统计所处理的请求数量。一种直观的方法是在servlet中增加一个long类型的域,并且每处理一个请求就将这个值加1,如程序2-2中:

这样,这个类就不是线程安全的了。虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而他并不会作为一个不可分割的操作来执行。实际上,包含了三个独立的操作:读取count,加1,然后将结果写入count。这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。 此时,当两个线程在没有同步的情况下对这个计数器进行递增操作时,如果计数器初始值为9,那么某些情况下,每个线程读到的都是9,接着执行递增操作,并且都将计数器的值设为10。显然,这种情况丢失了一次递增操作。

在并发编程中,这种由于不恰当执行时序而出现不正确的结果是一种非常重要的情况,他有一个正式的名字:竞态条件(Race Condition)

2.1 竞态条件

当某个计算的准确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果取决于运气。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步动作。

2.2 示例:延迟初始化中的竞态条件

延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。在程序2-3中lazyInitRace说明了这种延迟初始化情况。

在此类中包含了一个竞态条件,他可能会破坏这个类的正确性。假设线程A和线程B同时执行getInstance。A看到instance为空,因而创建一个新的实例。B同样需要判断instance是否为空。此时的instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例。

2.3 复合操作

我们将“先检查后执行”以及“读取-修改-写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么B全部执行完,要不完全不执行B,
那么A和B对彼此来说是原子的。
原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
复制代码

解决复合操作,可以使用加锁机制,将在下一小节介绍。目前使用另一种方式来修复这个问题,即使用一个现有的线程安全类,如程序2-4:

通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问都是原子的。

3. 加锁机制

假设希望提升servlet的性能:将最近的计算结果缓存出来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果。要实现该缓存策略,需要保存两个状态:最近执行过因数分解的数值,以及结果。

我们尝试用添加线程安全状态变量来完成这件事,UnsafeCachingFactorizer的代码为:

//    2-5  该Servlet在没有足够原子性保证的情况下对最近计算结果进行缓存(不要这么做)
@NotThreadSafe
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {//AtomicReference是作用是对"对象"进行原子操作private final AtomicReference<BigInteger> lastNumber= new AtomicReference<BigInteger>();private final AtomicReference<BigInteger[]> lastFactors= new AtomicReference<BigInteger[]>();public void service(ServletRequest req, ServletResponse resp) {BigInteger i = extractFromRequest(req);if (i.equals(lastNumber.get()))encodeIntoResponse(resp, lastFactors.get());else {BigInteger[] factors = factor(i);lastNumber.set(i);lastFactors.set(factors);encodeIntoResponse(resp, factors);}}void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {//保存执行过因数分解的数值及其结果}BigInteger extractFromRequest(ServletRequest req) {  return new BigInteger("7");}BigInteger[] factor(BigInteger i) {return new BigInteger[]{i};}
}复制代码

然而,尽管这些原子引用本身各自都是线程安全的,但在UnsafeCachingFactorizer中存在着竞态条件,这可能导致错误。

在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行顺序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。只有确保了这个不变性条件不被破坏,上面的Servlet才是正确的。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,在更新某一个变量时,需要在同一个原子操作中队其他变量同时进行更新。

在使用AtomicReference的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumberlastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破环了。同样,我们也不能确保会同时获取两个值:线程A获取这两个值得过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
复制代码

3.1 内置锁

Java提供一种内置的锁机制来支持原子性: 同步代码块(Synchronized Block)

同步代码块包括两部分:一个作为锁的对象引用,一个作为这个锁保护的代码块。

以关键字synchronized(同步的)来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

synchronized(lock){ //访问或修改由锁保护的共享状态
}
复制代码

每个Java对象都可以用做一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)或者监视锁(Monitor Lock)。线程在进入代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,无论是通过正常路径退出还是通过从代码块中抛出异常退出。获得内置锁的唯一路径就是进入由这个锁保护的同步代码块或方法。

Java的内置锁相当于一种互斥体(或互斥锁),这意味这最多只有一个线程能持有这种锁。如果线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,知道B释放这个锁。如果B一直不释放这个锁,那么A将一直等待。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块也不会相互干扰。

并发环境中的原子性与事务应用程序中的原子性有着相同的含义——一组语句作为不可分割的单元被执行。

下面我们使用synchronized关键字来改进:

//   2-6    这个Servlet能正确缓存最新的计算结果,但并发性却非常糟糕(不要这么做)
@ThreadSafe
public class SynchronizedFactorizer extends GenericServlet implements Servlet {@GuardedBy("this") private BigInteger lastNumber;@GuardedBy("this") private BigInteger[] lastFactors;public synchronized void service(ServletRequest req,ServletResponse resp) {BigInteger i = extractFromRequest(req);if (i.equals(lastNumber))encodeIntoResponse(resp, lastFactors);else {BigInteger[] factors = factor(i);lastNumber = i;lastFactors = factors;encodeIntoResponse(resp, factors);}}
}
复制代码

尽管SynchronizedFactorizer是线程安全,然而这种方法却过于极端,因为多个客户端无法同时使用因数分解Servlet,服务的响应性非常低。

3.2 重入

内置锁是可重入的,如果某个线程试图获得一个已经由它持有的锁,那么这个请求就会成功。”重入“获取锁操作的基本单位是“线程”而不是“调用”。

重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并将获取值设置为1,如果同一线程再次获取这个锁,计数值递增,而当线程退出同步代码块时,计数器会相应地递减,当计数值为0时,这个锁将被释放。

“重入”进一步提升了加锁行为的封装性(encapsulation),因此简化了面向对象(Object-Oriented)并发代码的开发。
复制代码

在以下代码中,子类改写了synchronized修饰的方法,然后调用父类中方法,如果没有可重入的时,这段代码将产生死锁。由于子类和父类的doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁。如果内置锁是不可重入,那么在调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去。重入避免了这种死锁情况的发生。

// 2-7 如果内置锁不是可重入的,这段代码将发生死锁
public class Widget {public synchronized void doSomething() {
...}
}
public class LoggingWidget extends Widget {public synchronized void doSomething() {System.out.println(toString() + ": calling doSomething");super.doSomething();}
}复制代码

4. 用锁来保护状态

锁能以串行形式访问其保护的代码路径,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵守这些协议,就能确保状态的一致性。

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们成状态变量是由这个锁保护的。

上面的SynchronizedFactorizer(实现了Servlet接口)中,lastNumberlastFactors这两个变量都是由Servlet对象的内置锁来保护的。

对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,对对象的域并一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象。某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,是为了免去显式地创建锁对象。需自行构造加锁协议或同步策略来实现对共享状态的安全访问,并且在程序中一直使用它们。

每个共享和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有放问可变状态的代码路径进行同步,使得对该对象不会发生并发访问。例如Vector和其他的同步集合类都使用了这种模式。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。如果在添加新的方法或代码路径时忘记使用同步,那么这种加锁协议就很容易被破坏。

只有被多个线程同时访问的可变数据才需要通过锁来保护,单线程程序不需要同步。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

不加区别地滥用synchronized,可能导致程序中出现过度同步。此外即使将每个方法都作为同步方法,在某些操作中仍然存在竞态条件。还会导致活跃性问题(Liveness)或性能问题(Performance)。

5. 活跃性(Liveness)和性能(Performance)

SynchronizedFactorizer中,通过Servlet对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个service方法进行同步。虽然这种简单且粗鲁的方法能确保线程安全,但代价却很高。

Servlet需要能同时处理多个请求,SynchronizedFactorizer违背了这个初衷。其他客户端必须等待Servlet处理完当前的请求,才能开始新的因数分解运算。这浪费了很多时间和减低了CPU的使用率。

下图给出了当多个请求同时达到因数分解Servlet时发生的情况:这些请求将排队等待处理。我们将这种Web应用程序称为不良并发(Poor Concurrency)应用程序: 可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。

通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性。CachedFactorizerServlet的代码修改为使用两个独立的同步代码块,一个同步代码块负责保护判断是否只需返回缓存结构的”先检查后执行”操作序列,另一个同步代码块负责确保对缓存的数值和因数分解结果进行同步更新。此外我们还引入了“命中计数器”,添加了“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置都使用同步。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程贡献,因此不需要同步。

//缓存最近执行因数分解的数值以及其计算结果的Servlet
@ThreadSafe
public class CachedFactorizer extends GenericServlet implements Servlet {@GuardedBy("this") private BigInteger lastNumber;@GuardedBy("this") private BigInteger[] lastFactors;@GuardedBy("this") private long hits;@GuardedBy("this") private long cacheHits;public synchronized long getHits() { //这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置都使用同步return hits;}public synchronized double getCacheHitRatio() {//这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置都使用同步return (double) cacheHits / (double) hits;}public void service(ServletRequest req, ServletResponse resp) {BigInteger i = extractFromRequest(req);BigInteger[] factors = null;synchronized (this) {     //负责保护判断是否只需返回缓存结构的"先检查后执行"操作序列++hits;if (i.equals(lastNumber)) {++cacheHits;factors = lastFactors.clone();//clone()会复制对象。所谓的复制对象,首先要分配一个和源对象同样大小的空间,在这个空间中创建一个新的对象。}}if (factors == null) {factors = factor(i);synchronized (this) {       //负责确保对缓存的数值和因数分解结果进行同步更新。lastNumber = i;lastFactors = factors.clone();}}encodeIntoResponse(resp, factors);}void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {}BigInteger extractFromRequest(ServletRequest req) {return new BigInteger("7");}BigInteger[] factor(BigInteger i) {return new BigInteger[]{i};}
}
复制代码

这里没有使用AtomicLong类型的命中计数器,而是使用long类型。对单个变量上实现原子操作来说,原子变量是很有用,但我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,所以这里不使用原子变量。

CachedFactorizerSynchronizedFactorizer相比,实现了简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上都需要一定开销,如果同步代码块分得太细(例如将++this分解为一个同步代码块),那样通常不好。

通常,在简单性与性能之间存在着互相制约因素。当实现某个同步策略时,一定不要盲目为了性能牺牲简单性,这可能破坏安全性。

当执行时间较长的计算或者无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。
复制代码

《Java并发编程实战》 第二章:线程安全性相关推荐

  1. 并发编程实战-第二章学习

    "共享"意味着变量可以由多个线程同时访问,而"可变"则意味着变量的值再其声明周期内可以发生变化. 如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那 ...

  2. Java并发编程开发笔记——2线程安全性

    在构建稳健的并发程序时,必须正确地使用线程和锁.但这些终归只是一些机制.要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问. ...

  3. Java7并发编程指南——第二章:线程同步基础

    Java7并发编程指南--第二章:线程同步基础 @(并发和IO流) Java7并发编程指南第二章线程同步基础 思维导图 项目代码 思维导图 项目代码 GitHub:Java7ConcurrencyCo ...

  4. 【极客时间】《Java并发编程实战》学习笔记

    目录: 开篇词 | 你为什么需要学习并发编程? 内容来源:开篇词 | 你为什么需要学习并发编程?-极客时间 例如,Java 里 synchronized.wait()/notify() 相关的知识很琐 ...

  5. 前置条件,不变性条件,后置条件 --《java并发编程实战》

    阅读<java并发编程实战>4.1.1章 收集同步需求时, 反复出现了"不变性条件","不可变条件","后验条件",令我一头雾水 ...

  6. 视频教程-Java并发编程实战-Java

    Java并发编程实战 2018年以超过十倍的年业绩增长速度,从中高端IT技术在线教育行业中脱颖而出,成为在线教育领域一匹令人瞩目的黑马.咕泡学院以教学培养.职业规划为核心,旨在帮助学员提升技术技能,加 ...

  7. Java并发编程实战之互斥锁

    文章目录 Java并发编程实战之互斥锁 如何解决原子性问题? 锁模型 Java synchronized 关键字 Java synchronized 关键字 只能解决原子性问题? 如何正确使用Java ...

  8. 《Java 并发编程实战》--读书笔记

    Java 并发编程实战 注: 极客时间<Java 并发编程实战>–读书笔记 GitHub:https://github.com/ByrsH/Reading-notes/blob/maste ...

  9. Java并发编程实战笔记2:对象的组合

    设计线程安全的类 在设计现车让安全类的过程之中,需要包含以下三步: 找出构成对象状态的所有变量 找出约束状态变量的不变性条件 建立对象状态的并发访问策略 实例封闭 通过封闭机制与合适的加锁策略结合起来 ...

  10. Java 并发编程——Executor框架和线程池原理

    Java 并发编程系列文章 Java 并发基础--线程安全性 Java 并发编程--Callable+Future+FutureTask java 并发编程--Thread 源码重新学习 java并发 ...

最新文章

  1. mimind(思维导图软件)中文版
  2. win10电脑插耳机没声音_Win10如何录制电脑内部播放的声音
  3. datax调研及增量更新的思路
  4. 基于SLF4J MDC机制实现日志的链路追踪
  5. Flink 在又拍云日志批处理中的实践
  6. angular.js前端和后台的数据交换,后台取不到值对应方案
  7. springcloud gateway 自定义 accesslog elk
  8. 最简单的moss单点登录第三方系统,有点非主流
  9. SVN数据代码迁移Windows2012ServerR2
  10. 如何使用W5300实现ADSL连接(二)
  11. [笔记]TB-6S-LX150T-IMG2_HWUserManual_1.02e实例讲解
  12. java 常量 类型_Java的常量及数据类型。
  13. FCFS,SJF,HRRN调度算法
  14. 微软SQLHelper.cs类 中文版
  15. Gephi可视化(二)——Gephi Toolkit叫板Prefuse
  16. android 设为锁屏壁纸,修改设置Android锁屏壁纸为系统默认的锁屏壁纸
  17. 粤省事:随时、随地、随处的便民解决方案
  18. Proxmox VE的初试小探
  19. svg果冻弹性按钮动画js特效
  20. UG翼型参数化建模方法及代码

热门文章

  1. C# 给枚举类型增加一个描述特性
  2. 软件需求分析教程阅读笔记二
  3. 会议室预定模拟登陆网站
  4. flume http source示例讲解
  5. 34.Odoo产品分析 (四) – 工具板块(5) – 设备及联系人目录(1)
  6. webpack,vue中定义的别名怎么在模板, css sass less的图片地址上使用
  7. Redis数据类型之字符串String
  8. 运用c语言和Java写九九乘法表
  9. ASCII 码表对照 2
  10. 【转载】【凯子哥带你学Framework】Activity界面显示全解析(下)