【读过的书,走过的路】 Java并发编程实战——线程安全性

  要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。
  当多个线程访问某个状态变量并且其中一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中主要的同步机制是synchronized关键字、volatile类型变量、显式锁以及原子变量。
  如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。修复这个问题的三种方式:

  • 不在线程之间共享该状态变量。
  • 将状态变量修改为不可变的变量。
  • 在访问状态变量时使用同步。
  完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。任何情况下,只有当类中仅包含自己的状态时,线程安全类才是有意义的。

1.1 什么是线程安全性

  线程安全性最核心的概念就是正确性。它的含义是,某个类的行为与其规范完全一致。所以,当多个线程访问某个类时,不管运行时环境采用何种调度方式或者线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的
  如果一个类是无状态的,则它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。
  由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象一定是线程安全的。(大多数Servlet都是无状态的,降低实现Servlet线程安全性时的复杂性。只有当Servlet在处理请求时需要保存一些信息,线程安全才会成为问题)

1.2 原子性

  递增操作++i是看上去只是一个操作,但这个操作并非原子的,它包含了三个独立的操作:读取i的值,将值加1,然后将计算结果写入count。这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。
  在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是很严重的情况,叫做竞态条件

1.2.1 竞态条件

  当某个计算的正确性取决于多个线程的交替执行时,就会发生竞态条件。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。
  “先检查后执行”:首先观察到某个条件为真,然后根据这个观察结果采用相应的动作,但事实上,在观察到这个结果以及开始创建文件之前,观察结果可能变得无效从而导致各种问题。

1.2.2 延迟初始化中的竞态条件

  延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。但这样很容易产生“先检查后执行”的情况。线程A和线程B同时创建实例,A看到实例为空就创建一个新的实例,而B也判断实例为空,就会也创建一个新的实例,从而得到不同的结果。

1.2.3 复合操作

  要避免竞态条件,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态。
  将“先检查后执行”以及“读取-修改-写入”等称为复合操作:包含了一组必须以原子方式执行的操作来确保线程安全性。
  在java.util.concurrent.atomic包中包含了原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来替代long类型,能够确保所有对其状态的访问操作都是原子的。(++i的操作优化后就是AtomicLong i = new AtomicLong(0); i.incrementAndGet();就使其变为原子操作)
  **当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。**然而当状态变量的数量由一个变为多个时,就不像从零个变为一个这样简单。

1.3 加锁机制

  当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。所以,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
  要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

1.3.1 内置锁

  Java提供了同步代码块的内置锁机制来支持原子性。同步代码块包含两部分:锁的对象引用以及这个锁保护的代码块。
  每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,在退出同步代码块时自动释放锁。
  Java内置锁相当于一种互斥体,意味着最多只有一个线程能持有这种锁。所以,这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码时也不会互相干扰。

1.3.2 重入

  当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。但由于内置锁是可重入的,所以如果在某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。

1.4 用锁来保护状态

  由于锁能使其保护的代码路径以串行形式来访问,所以可以通过锁来构造一些协议以实现对共享状态的独占访问。
  访问共享状态的复合操作,都必须是原子操作来避免产生竞态条件。如果同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
  每个共享和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。常见的加锁约定:将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。
  当类的不变性条件涉及多个状态变量时,那么还需要在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。
  虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制。将每个方法都作为同步方法还可能导致活跃性问题或性能问题。

1.5 活跃性与性能

  不良并发:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。通过缩小同步代码块的作用范围,可以容易做到确保并发性,又维护安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
  对于单个变量上实现原子操作来说,原子变量很有用。但若已经使用同步代码块来构造原子操作就不要使用原子变量。
  要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性、简单性和性能。当执行时间较长的计算或者可能无法快速完成的操作时,比如网络I/O或控制台I/O,一定不要持有锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值