Java并发编程实战——学习笔记(一)

一、线程安全性


在线程安全性中,最核心的概念是正确性,而正确性的含义是:某个类的行为与其规范完全一致。这里的规范可以粗略理解为在各种限定条件下,类对象的结果与预期一致。在单线程中,正确性可以近似的定义为“所见即所知(we know it when we see it)”。在大概明确了“安全性”的概念后,我们可以认为线程安全性就是:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就可以认为是线程安全的。

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

也可以将线程安全类认为是一个在并发环境和单线程环境中都不会被破坏的类。如果某个类在单线程环境下都不是线程安全类,那么它肯定不是线程安全类。下面是一个线程安全类的示例:

public class StatelessFactorizer implements Servlet{
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

这个StatelessFactorizer是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。方法中的局部变量只能由正在执行的线程访问。如果同时有多个线程在访问StatelessFactorizer,那么这些线程之间将不会互相影响,因为线程之间并没有共享状态,就好像在访问不同的实例。

由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的,且无状态对象一定是线程安全的。


二、原子性

什么是原子性呢?其实原子性就是一个不可再分割的性质,不能再分成更细的粒度。

如果我们在刚刚的示例中增加一个状态(既一个计数器),用来统计已处理请求数量,每处理一个请求就将这个值加1,程序如下所示:

public class StatelessFactorizer implements Servlet{
	private long count = 0;
	
	public long getCount(){return count;}
	
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}

在上面的程序示例中,咋一看没问题,++count看起来像是一个操作,但是这个自增操作并非原子性的。因为实际上,它包含了三个操作:“读取-修改-写入”的操作序列。每个操作都依赖于前面之前的状态。如果此时有两个线程A、B,如果A线程已经进行到了修改操作,此时如果B线程进行了读取,那么最终A、B线程写入的值是一样的,这样就与预期结果偏差了1.

虽然在这里看起来,结果偏离了一些可以接受,但是如果这个计数器的值被用来生成数值序列或唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。

在并发编程中,像这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,这种情况叫做“竞态条件(Race Condition)”。


竞态条件

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

举个栗子:你和朋友约好一起去网吧开黑,你当了网吧的时候,发现你朋友不在,此时你可能选择呆在网吧里等他,也可能去他家找他,如果你去找他,那么当你出了网吧以后,你在网吧的观测结果(朋友不在)就可能失效了,因为他可能在你去他家找他的路上已经到了网吧,而你却去找他了。

这个栗子中,正确的结果是(你们在网吧会面),但是这个结果取决于事件发生的时序(既谁先到网吧并且等待对方的时长)。这种观察结果的失效就是大多数竞态条件的本质——基于一种可能失效的观测结果来做出判断或者执行某个计算。

再举个栗子,假设有两个线程A、B,A、B线程都用来判断某个文件夹是否存在,不存在就创建它,假如当A线程发现文件夹不存在时,正打算创建文件夹,但是此时B线程已经完成了文件夹的创建,那么此时A线程观测的结果就已经失效了,但是A线程依旧根据这个已失效的观测结果在进行下一步动作,这就可能会导致各种问题。

使用“先检查后执行”的一种常见的情况就是延迟初始化。就比如在单例模式中有一种写法如下:

public class LazyInitRace {
	private static LazyInitRace instance = null;
	
	public LazyInitRace getInstance(){
		if(instance == null){
			instance = new LazyInitRace();
		}
		return instance;
	}
}

这就是典型的延迟初始化,在单线程中这样写没毛病,但是在多线程环境中,如果有A、B线程同时执行getInstance()方法,那么结果可能符合预期,也可能会得到两个不一样的对象。因为在A线程发现instace为null时,B线程可能也同时发现instace为null。

与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序,但是如果发生问题,那么可能导致很严重的问题。

在上面的示例中都包含了一组需要以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改过程中

在上面统计已处理请求数量的示例中,我们可以使用AtomicLong对象来替换long,因为AtmoicLong类是线程安全类,所以可以保证示例也是示例安全的,但是在添加一个状态变量时,是否还可以通过使用线程安全的对象来管理而类的状态以维护其线程安全性呢?如下所示:

public class UnsafeCachingFactorizer implements Servlet {
	private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();

	private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

	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);
		}
	}
}

在上述例子中,虽然两个变量都是线程安全的,但是在service方法中依然存在竞态条件,因为在上述例子中,类的不变性条件已经被破坏了,只有确保了这个不变性条件不被破坏,才是正确的。当不变性条件中涉及到了多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。

在上述例子中,虽然set方法是原子操作,但是在set方法无法同时更新lastNumber和lastFactors。如果当一个线程执行了lastNumber.set()方法还没执行下一个set方法时,如果此时有一个线程访问service方法,那么得到的结果就与我们所预期的不一致了。

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

三、加锁机制

3.1内置锁

在Java中提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包含两部分:一个是作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象(this).静态的synchronized方法以Class对象为作为锁。

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或是监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。

Java的内置锁相当于一种互斥锁,最多只有一个线程能持有这种锁。当线程A尝试获取线程B持有的锁时,线程A必须等待或阻塞,知道线程B释放了该锁。如果线程B不释放锁,则线程A也将永远等下去。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

下面时应用了内置锁的示例:

public class SynchronizedFactorizer implements Servlet {
	private BigInteger lastNumber;

	private BigInteger[] lastFactors;

	public synchronized void service(ServletRequest req, ServletResponse resp) {
		BigInteger i = extractFromRequest(req);
		if (i.equals(lastNumber)) {
			encodeIntoResponse(resp, lastFactors.get());
		} else {
			BigInteger[] factors = factor(i);
			lastNumber = i;
			lastFactors = factors;
			encodeIntoResponse(resp, factors);
		}
	}
}

虽然使用synchrnoized关键字保证了结果的正确性,但是在同一时刻只有一个线程可以执行service方法,这就导致了服务的响应性非常低,并发性非常的糟糕,变成了一个性能问题,而不是线程安全问题。

3.2 重入

当某个线程请求一个由其他线程持有的锁是,发出请求的线程就会被阻塞,但是,由于内置锁是可重入的,即如果某个线程试图获得一个已经由它自己持有的锁时,那么这个请求就会成功。"重入"意味着获取锁的操作粒度是“线程”而不是“调用”。重入的一种实现方法就是,为每一个锁关联一个计数值和一个所有者线程。当计数值为0时,就认为这个锁是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数值会相应的递减。当计数值为0时,这个锁将被释放。

下面是一个重入的例子:

public class Widget{
    public synchronized void doSomething(){
        System.out.println(toString() + ": calling doSomething");
    }
}
public class LoggingWidget extends Widget{
    public synchronzied void doSomething(){
           System.out.println(toString() + ": calling doSomething");
           super.doSomething();
    }
}

在上述例子中,LoggingWidget继承了Widget并改写了父类,并且都是用了synchronized关键字修饰doSomething方法,如果子类对象在调用doSomething方法时。如果没有可重入锁,那么这段代码就会产生死锁。因为每个doSomething方法在执行前都会获得Widget上的锁,如果内置锁是不可重入的,那么在调用super.doSomething时就无法获得Widget上的锁,因为这个锁已经被持有了,从而线程将永远停顿下去,等待一个永远也无法获得的锁。注意:在这里synchronized关键字修饰的是方法体,也就是说它锁住的是对象本身(this),所以当第一次进入doSomething方法时,锁住的是LoggingWidget对象,而在调用super.doSomething时,并没有新建一个父类对象,锁的对象还是this.

四、用锁来保护状态

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。对象的内置锁与其状态之间没有内在的关联,对象的域并不一定要通过内置锁类保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁,每个对象都有一个内置锁。

每个共享的和可变的变量都应该只由一个锁来保护。一种常见的加锁约定是,将所有可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路劲进行同步,使得在该对象上不会发生并发访问。但是,如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。

我们应该知道的是,并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。当某个变量由锁来保护时,意味着每次访问这个变量时都需要首先获得这个锁,这样就确保在同一时刻只有一个一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。

虽然同步可以避免竞态条件问题,但并不意味着可以在每个方法声明时都是用关键字synchronized.如果将程序中存在过多的同步方法,可能会导致活跃性问题或性能问题。

我们应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,确保同步代码块中尽量只存在原子性的操作。

在使用锁时,应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间,当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁!!!

发布了34 篇原创文章 · 获赞 31 · 访问量 5035
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章