Java并发编程实战(一)

作者: Ian | 2018-06-17 | 阅读

引言

看标题就知道肯定有二,加深自己的理解和印象,如果有什么不对或者有争议的地方请留言指出,thanks!

简介

为什么要编写并发程序?线程是java语言中不可或缺的重要功能,它们能使复杂的异步代码变得更简单,从而极大地简化了复杂系统的开发。此外,想要充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。

并发简史

  • 资源利用率:在某些情况下,程序必须等待某个外部操作执行完成,例如,输入操作或输出操作等,而在等待时程序无法执行其他任何工作。因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率。
  • 公平性:不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片是这些用户和程序能共享计算机资源,而不是由一个程序从头运行到尾,然后再启动下一个程序。
  • 便利性:通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。

线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,例如:内存句柄和文件句柄,但每个线程都有各自的程序计数器、栈以及局部变量等。线程还提供了一中直观的分解模式来充分利用多处理器系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行 。同一个进程中的所有线程都将共享进程的内存地址空间。

线程的优势

如果使用得当,线程可以有效地降低程序的开发和维护等成本,同时提升复杂应用程序的性能。线程能够将大部分的异步工作流转换成串行工作流,因此能更好地模拟人类的工作方式和交互方式。此外,线程还可以降低代码的复杂度,是代码更容易比编写、阅读和维护。

异步事件的简化处理

服务器应用程序在接受来自多个远程客户端的套接字连接请求时,如果为每个连接都分配其各自的线程并且使用同步I/O,那么就会降低这类程序的开发难度。

如果某个应用程序对套接字执行读操作而此时还没有数据到来,那么这个读操作将一直堵塞,直到有数据到达。在单线程应用程序中,这不仅意味着在处理请求的过程中将停顿,而且还意味着在这个线程被堵塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞I/O,这种I/O的复杂性要远远高于同步I/O,并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其它请求的处理。

活跃性问题

在开发并发代码时,一定要注意线程安全性是不可破坏的。

安全性的含义是:“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。

性能问题

性能问题包括多个方面,例如:服务时间过长,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。

线程无处不在

Timer 类的作用是使任务在稍后时刻运行,或者运行一次,或者周期性地运行。通常,要实现以线程安全的方式访问数据,最简单的方式是确保TimerTask访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部。

Servlet框架用于部署网页应用程序以及分发来自HTTP客户端的请求,在Servlet规范中,同样需要满足被多个线程同时调用,换句话说,Servlet需要是线程安全的。

Servlet和JSP,以及在ServletContext和HttpSession等容器中保存的Servlet过滤器和对象等,都必须是线程安全的。

基础知识

线程安全性

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

从非正式的意义上来说,对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。在对象状态中包含了任何可能影响其外部可见行为的数据。

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的同步机制是关键字synchronize,它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显示锁以及原子变量。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  1. 不在线程之间共享该状态变量。
  2. 将状态变量修改为不可变得变量。
  3. 在访问状态变量时使用同步。

在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求高速你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。

什么是线程安全性

正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及各种后验条件来描述对象操作的结果。可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

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

示例:一个无状态的Servlet

/*ThreadSafe是可选的,如果一个类没有标注为线程安全的,那么就应该加上它不是线程安全的,但如果想明确地表示这个类不是线程安全的,那么就可以使用@NotThreadSafe
*/
@ThreadSafe
public class StatelessFactorizer implements Servlet{
    public void service (ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

无状态对象一定是线程安全的。

大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性。只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

竞态条件

竞态条件这个术语很容易与另一个相关术语“数据竞争”相混淆。数据竞争是指,如果在访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争。并非所有的竞态条件都是数据竞争,同样反之。在UnsafeCountingFactorizer中既存在竞态条件,又存在数据竞争。

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

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

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。

/*ThreadSafe是可选的,如果一个类没有标注为线程安全的,那么就应该加上它不是线程安全的,但如果想明确地表示这个类不是线程安全的,那么就可以使用@NotThreadSafe
*/
@NotThreadSafe
public class LazyInitRace{
    private ExpensiveObject instance = null;
    public ExpensiveObject getInstance(){
        if(instance == null){
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

复合操作

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

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

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

/*
1.ThreadSafe是可选的,如果一个类没有标注为线程安全的,那么就应该加上它不是线程安全的,但如果想明确地表示这个类不是线程安全的,那么就可以使用@NotThreadSafe

2. 使用AtomicLong类型的变量来统计已处理请求的数量
*/
@ThreadSafe
public class CountingFactorizer implements Servlet{
    private final AtomicLong count = new AtomicLong(0);
    public long getCount(){
        return count.get();
    }
    public void service(ServietRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp,factors);
    }
}

在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。

加锁机制

示例:该Servlet在没有足够原子性保证的情况下对其最近计算结果进行缓存(不要这么做)

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet{
	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);
        }
	}
}

UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。

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

内置锁

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

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

每个Java对象都可以用做一个现实同步的锁,这些锁被称为内置锁或监视器锁。并发环境中的原子性与事务应用程序中的原子性有着相同的含义 – 一组语句作为一个不可分割的单元被执行。

示例:Servlet能正确地缓存最新的计算结果,但并发性却非常糟糕(不要这么做)

/*
@GuardedBy(“this”) ,表示在包含对象上的内置锁(被标注的方法或域是该对象的成员)
*/
@ThreadSafe
public class SynchronizedFactorizer 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);
        }
    }
}

重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁时可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

示例:如果内置锁不是可重入的,那么这段代码将发生死锁

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

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

用锁来保护状态

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

访问共享状态的复合操作,例如:命中计数器的递增操作(读取 - 修改 - 写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

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

之所以每个对象都一个内置锁,只是为了免去现实地创建锁对象。在SynchronizedFactoriser类中说命了这条规则:缓存的数值和因数分解结果都是由Servlet对象内置锁来保护

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

活跃性与性能

在UnsafeCachingFactorizer中,我们通过在因数分解Servlet中引入了缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用UnsafeCachingFactorizer中的同步方式,那么代码的执行性能将非常糟糕。

通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。有时候,在简单性与性能之间会发生冲突,但在二者之间通常能找到某种合理的平衡。

例子:缓存最近执行因数分解的数值及其计算结果的Servlet

/*
@GuardedBy(“this”) ,表示在包含对象上的内置锁(被标注的方法或域是该对象的成员)
*/
@ThreadSafe
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 synchronized double getCacheHitRatio(){
		return (double)cacheHits / (double)hits;
	}
	public vlid 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(resp, factors);
	}
}

版权声明:本文由 Ian 在 2018年06月17日发表。本文采用CC BY-NC-SA 4.0许可协议,非商业转载请注明出处,不得用于商业目的。
文章题目及链接:《Java并发编程实战(一)》




  相关文章:


留言区:

TOP