Java并发编程实战笔记 —— 第2章 线程安全性

线程安全性简介

讨论一个对象是否需要实现线程安全的前提条件是:该对象可以被多个线程访问。

(也就是说如果一个对象仅仅只是被单线程访问,那么不需要讨论其线程安全性)

编写线程安全的代码,核心在于要对状态访问操作进行管理,特别是对于共享的 (shared) 和可变 (mutable) 的状态的访问。

  • 共享意味着变量可以由多个线程同时访问;
  • 可变意味着变量的值在其生命周期内可以发生变化;

要使得一个对象是线程安全的,就必需要采用同步机制来协同对该对象可变状态的访问。

  • 对象的状态

    从非正式意义上来说,对象的状态指:存储在状态变量中的数据,比如实例或者静态域中的数据;对象的状态可能包括其他依赖对象的域;比如HashMap的状态不仅存储在其本身,还存储在多个Map.Entry 对象中。

    对象的状态中包含了任何可能影响其外部可见行为的数据。

  • 同步机制

    如果某个对象的状态变量可以被多个线程同时访问,且其中有线程对其执行写入操作,那么就需要采用同步机制来协同这些线程对该变量的访问。

    JAVA中同步机制的关键字是 synchronized,它提供了一种独占的加锁方式,但“同步”还包括volatile类型的变量,显式锁 explicit lock 以及原子变量等。

修复没有使用同步机制的多线程访问:

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

那么,什么是线程安全的类

在多线程的情况下,如果这个类的对象在任何时刻只能被一个线程访问,那么这个类就是线程安全的类;或者说,多线程同时运行的情况下,这些线程同时去访问这个类的对象实例,同时去执行一些方法,但是每次的运行结果都是确定的,和单线程执行的结果是一致的,那么这个类就是线程安全的类。

在任何情况中,只有当类中仅包含自己的状态时,线程安全类才是有意义的。

线程安全性是一个在代码上使用的术语,但他只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。

2.1 线程安全性

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

良好的规范中通常会定义各种不变性条件 (Invariant) 来约束对象的状态,以及定义各种后验条件 (Postcondition) 来描述对象操作的结果。

无状态对象一定是线程安全的。无状态对象指的是不包含任何域也不包含任何对其它类中域的引用的对象。

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类依旧是线程安全的。但是有多个状态变量时,情况会变得更加复杂。

也就是说,当不变性条件中涉及多个变量时,各个变量之间可能并不是彼此独立的,某个变量的值可能会对别的变量的值产生约束,由此,当更新其中一个变量时,另一个变量同样也需要在同一个原子操作中同步更新。

比如在无状态的类中添加两个状态,尽管这两个状态分别由不同的线程安全的对象来管理,但是这两个状态之间可能会有依赖或者约束的关系,比如状态A的值取决于状态B的值,那么这个类依旧可能不是线程安全的。

2.2 原子性

竞态条件 race condition

定义:并发编程中,由于不恰当的执行时序而出现不正确的结果的情况。

竞态条件发生的情境:当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。

常见的竞态条件类型:

  • ”先检查后执行 check-then-act“操作,即通过一个可能失效的观测结果来决定下一步的动作。

    先检查后执行的竞态条件的本质:基于一种可能失效的观察结果来做出判断或者执行某种计算。

    先检查后执行的案例:延迟初始化

    public class LazyInitRace {
    	private ExpensiveObject instance = null;
    
    	public ExpensiveObject getInstance() {
    		if (instance == null)
    			instance = new ExpensiveObject();
    		return instance;
    	}
    }
    
    // LazyInitRace类便是一个check-then-act操作的案例
    // 当一个线程检查instance为空,正在初始化新的ExpensiveObject对象实例时,另一个线程有可能正在检查到instance为null并进入下一步
    
  • “读取-修改-写入”操作

    int count = 0;
    // some other code...
    count++;
    
    // 自加的操作便是先读取count的值,再加一,再赋给count,此过程中线程不安全
    

原子操作Atomic operations

我的理解:操作是由线程执行的,这些操作可能是修改对象的某个状态等,这些操作所读取的值或者修改的值可能相互依赖或者相互约束,如果不按顺序、不按时序进行,可能就会得到不确定的结果,也就是说线程不安全;但是如果线程执行这些操作时,要么不执行,要么直接执行完才释放给别的线程,那么这些操作之间就是彼此独立的,原子的;

比如:count++操作,如果能够保证某个线程读取修改写入的过程中,别的线程只能等待,那么count++这个操作就可以算作原子操作;要保证这样的过程,可以通过加锁机制来完成。

java.util.concurrent.atomic 包提供了一些原子变量类,主要用于数值和对象引用上的原子状态转换。

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicIntegerFieldUpdater
  • AtomicLong
  • AtomicLongArray
  • AtomicLongFieldUpdater
  • AtomicMarkableReference
  • AtomicReference
  • AtomicReferenceArray
  • AtomicReferenceFieldUpdater
  • AtomicStampedReference
  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder
  • Striped64

2.3 加锁机制

对于单个状态变量可以通过原子变量类来实现其原子操作;

但是当存在多个状态变量时,要保证多个线程之间的操作无论采用何种执行时序或交替方式的不变性条件不被破坏,仅仅使用原子变量类是不够的。

AtomicLong a1;
AtomicLong a2;

// some code to restraint a1 and a2

a1.incrementAndGet()
a2.incrementAndGet()

// 执行上述两步操作时,存在竞态条件

可以通过Java内置的机制——加锁机制以确保原子性。

内置锁:同步代码块 Synchronized Block

每个JAVA对象都可以用作一个实现同步的锁,这些锁被称为内置锁 intrinsic lock 或监视器锁 monitor lock.

线程在进入同步代码块之前就会自动获得锁,并且在退出同步代码块时自动释放锁。

获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

Java的内置锁相当于一种互斥锁,也就是说最多只有一个线程能够持有这种锁。

由内置锁保护的代码块是按原子方式执行的,多个线程执行该段代码都互不干扰。

但是,由于内置锁的特性,同一时刻只能有一个线程执行该段代码,该段代码的性能就低下了。

重入

内置锁是可以重入的,因此如果某个线程试图获得一个已经有它自己持有的锁,那么这个请求就会成功。

重入意味着获取锁的操作的粒度是线程,而不是调用。

应用:面向对象的开发中,子类可能改写了父类的synchronized方法,然后又调用父类中的方法,如果没有可以重入的锁,那么这段代码将产生死锁。

2.4 用锁来保护状态

需要使用同步的情况不仅仅是在需要修改写入共享变量时,同样也包括访问该变量时。

2.5 活跃性与性能

同步代码块应当尽可能地精确,直接将整个函数放入同步代码块中可能会导致性能不足。

案例:

// 一个带计数器hits和缓存上次计算结果的求解因子的类,factor方法为求因子的具体实现,未列出

public class CachedFactorizer 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 void service(ServletRequest req, ServletResponse resp) {
		BigInteger i = extractFromRequest(req);
		BigInteger[] factors = null;
		synchronized (this) {
			++hits;
			if (i.equals(lastNumber)) {
				++cacheHits;
				factors = lastFactors.clone();
			}
		}
		if (factors == null) {
			factors = factor(i);
			synchronized (this) {
				lastNumber = i;
				lastFactors = factors.clone();
			}
		}
		encodeIntoResponse(resq, factors);
	}
}

// 该方法实现相比于直接将 CachedFactorizer.service 函数包装成synchronized 要合理、平衡很多

当使用锁时,开发者应当清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。

05-26 13:23