码迷,mamicode.com
首页 > 编程语言 > 详细

Java并发编程实战

时间:2020-11-30 16:09:09      阅读:11      评论:0      收藏:0      [点我收藏+]

标签:tst   不能   统计   支持   一个   基本   出现   循环   内存操作   

简介

线程的优势:

  • 发挥多处理器强大的能力
  • 建模的简单性(为模型中的每种类型的任务都分配一个专门的线程)
  • 异步事件的简化处理
  • 响应更灵敏的用户界面

线程带来的风险

安全性问题

线程安全性可能是非常复杂的,在没有充分同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。

活跃性问题

安全性的定义是”永远不发生糟糕的事情”,而活跃性关注于另一个目标“某件正确的事情最终会发生“。当某个操作无法继续执行下去时,就会发生活跃性问题。活跃性问题的形式之一就是无意中造成的无限循环。

性能问题

在设计良好的并发应用程序中,线程能提高程序的性能。但无论如何,线程会带来一定的运行时开销。在多线程程序中,当线程调度器挂起一个活跃线程并转而运行另一个线程时,就会频繁出现上下文切换操作,这会带来极大的开销。

/*
* 1-1 非线程安全的数值序列生成器
* */
public class UnsafeSequence {
    private int value;

    /*
    * 返回一个独一无二的值
    * */
    public int getNext(){
        return value++;
    }

    public static void main(String[] args) {
        UnsafeSequence sequence = new UnsafeSequence();

        for(int i = 0; i < 10; i++){
            Thread t = new Thread(() -> {
                System.out.print(sequence.getNext() + "\t");
            });
            t.start();
        }
    }
}

在没有充分同步的情况下,生成的序列号可能相同(也可能全部不相同,但是多运行几次一定可以看到相同的序列号)。

一次运行结果:
0	3	2	1	0	5	4	6	7	8	

我们将 getNext 修改为一个同步方法(添加 synchronized),就可修复上面的错误,每次都可以得到唯一的序列号。

/*
* 1-2 线程安全的数值序列生成器
* */
public class Sequence {
    private int nextValue;

    public synchronized int getNext() {
        return nextValue++;
    }

    public static void main(String[] args) {
        Sequence sequence = new Sequence();

        for(int i = 0; i < 10; i++){
            Thread t = new Thread(() -> {
                System.out.print(sequence.getNext() + "\t");
            });
            t.start();
        }
    }
}

线程安全性

从非正式意义上来说,对象的状态是指存储在状态变量(例如实例和静态域)中的数据。共享意味着变量可以由多个线程同时访问,而可变意味着变量的值可以在生命周期内变化。

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

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

  1. 不在线程中共享该状态变量
  2. 将该状态变量设置为不可变的变量
  3. 在访问状态变量时使用同步

在编写并发程序时,一种正常的编程方法就是:首先使代码正确运行,然后提高代码的速度。

什么是线程安全性

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

在线程安全的类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。

class Request{//Response 和Request 类定义一样
    int value;
    //构造、setter、getter 省略
}

/*
* 2-1 一个无状态的 Servlet
* 线程安全
* */
public class AdderServlet{
	
    public void service(Request request, Response response){
        int value = request.getValue();
        System.out.println("Init Value: " + value);
        value += 6;
        System.out.println("Modified Value: " + value);
        request.setValue(value);
    }
}

与大多数 Servlet 相同,AdderServlet 是无状态的:它既不包含任何域,也不包含对任何其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。

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

原子性

当我们在无状态对象中增加一个状态时,会出现什么情况?我们在 Servlet 中增加一个 long 类型的域,用它来统计请求的次数。

/*
 * 2-2 在没有同步的情况下统计已请求数量的 Servlet
 * 非线程安全
 * */
public class UnsafeAdderServlet {
    private long count;

    public void service(Request request, Response response){
        int value = request.getValue();
        System.out.println("Init Value: " + value);
        value += 6;
        System.out.println("Modified Value: " + value);
        response.setValue(value);
        ++count;
    }

    public long getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        Request req = new Request(11);
        Response resp = new Response();
        UnsafeAdderServlet servlet = new UnsafeAdderServlet();
        for(int i = 0; i < 200000; i++){
            new Thread(() -> {
                servlet.service(req,resp);
                System.out.println(servlet.getCount());
            }).start();
        }
    }
}

我们调用 service 方法 200,000 次,最后的 count 也应该是 200,000 ,一次的运行结果却是 199,999。在并发量高的时候,count 值出现了偏差,这是因为自增操作包含三个独立的操作:读取 - 修改 - 写入,结果状态依赖于前面的状态。如果两个线程在没有同步的情况下对 count 变量进行自增操作,可能会带来偏差。以 count 初值为 9 为例:

Thread 1:     read(9)  -->  modify(9 + 1 = 10)  -->  wirteback(10)
Thread 2:                     read(9)  -->  modify(9 + 1 = 10)  -->  wirteback(10)

最终 count 的值为 10,而正确的值为 11 ,这产生了偏差。在并发编程中,这种由不正确的时序而出现的不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件

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

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

/*
* 2-3 延迟初始化中的竞态条件
* 非线程安全
* */
public class LazyInitRace {
    private ExpensiveObject instance = null;

    public ExpensiveObject getInstance() throws InterruptedException {
        if(instance == null){
            instance = new ExpensiveObject();
        }
        return instance;
    }

    public static void main(String[] args) {
        LazyInitRace lazyInitRace = new LazyInitRace();

        for(int i = 0; i < 3; i++){
            new Thread(() ->{
                try {
                    System.out.println(lazyInitRace.getInstance());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

class ExpensiveObject{
    public ExpensiveObject() throws InterruptedException {
        //假设创建对象时间为 200 ms,增加错误几率
        Thread.sleep(200);
    }
}
运行结果:
concurrencyinpractice.chap2.ExpensiveObject@674827d5
concurrencyinpractice.chap2.ExpensiveObject@674827d5
concurrencyinpractice.chap2.ExpensiveObject@22673956

字符@后面的十六进制数就是对象的哈希码值,在对同一个对象多次调用 hashcode 方法时,哈希码值应该不会改变,结果中出现了两个不同的哈希码值说明我们调用三次 getInstance 方法时,instance 被初始化了两次,这不是我们想要的结果。

为了保证线程安全性,“先检查后操作” 和 “读取 - 修改 - 写入” 操作必须是原子的,我们称这类操作为复合操作。可以使用锁来保证复合操作以原子方式执行,这里我们使用原子变量来修复 UnsafeAdderServlet 的错误。

import java.util.concurrent.atomic.AtomicLong;
/*
* 2-4 使用 AtomicLong 类型的变量来统计已处理请求的数量
* 线程安全
* */
public class SafeAdderServlet {
    private AtomicLong count = new AtomicLong(0);

    public void service(Request request, Response response){
        int value = request.getValue();
        System.out.println("Init Value: " + value);
        value += 6;
        System.out.println("Modified Value: " + value);
        response.setValue(value);
        count.incrementAndGet();
    }

    public long getCount(){
        return count.get();
    }

    public static void main(String[] args){
        Request req = new Request(11);
        Response resp = new Response();
        SafeAdderServlet servlet = new SafeAdderServlet();
        for(int i = 0; i < 500000; i++){
            new Thread(() -> {
                servlet.service(req,resp);
                System.out.println(servlet.getCount());
            }).start();
        }
    }
}

执行程序,我们看到最后的count 值为 500,000 与请求的次数相同。通过使用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问都是原子的。

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍是线程安全的。

在实际情况中,应尽可能地使用现有的线程安全对象(例如 AtomicLong)来管理类的状态。

我们希望提升 Servlet 的性能,将最近计算的结果缓存起来,当两个相同的请求数值到来时,可以直接使用上一次的计算结果,而无须重新计算。

我们通过 AtomicReference 来管理最近执行的数值和结果,它能保证线程安全性吗?

import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicReference;
/*
* 2-5 在没有足够原子性保证的情况下对最近计算结果进行缓存
* 非线程安全
* */
public class UnsafeCachingAdderServlet {
    private final AtomicReference<Integer> lastNumber = new AtomicReference<>();
    private final AtomicReference<Integer> lastResult = new AtomicReference<>();

    public void service(Request request, Response response) throws InterruptedException {
        print();
        if(request.equals(lastNumber.get())){
            response.setValue(lastResult.get());
        }else{
            response.setValue(request.getValue() + 6);
            lastNumber.set(request.getValue());
            Thread.sleep(3);
            lastResult.set(response.getValue());
        }
    }

    public void print(){
            System.out.println("lastNumber: " + lastNumber + "\t lastResult: " + lastResult);
    }

    public static void main(String[] args) {
        UnsafeCachingAdderServlet servlet = new UnsafeCachingAdderServlet();
        Response response = new Response();
        for(int i = 0; i < 50; i++){
            new Thread(() -> {
                try {
                    servlet.service(new Request((int)(Math.random() * 100)), response);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}
部分运行结果:
lastNumber: 18	 lastResult: null
lastNumber: 33	 lastResult: 27
lastNumber: 42	 lastResult: 83

这部分结果中,只有第二行是我们期待的结果,其他两行的 result != number + 6。代码中的两组操作(见注释)每一个操作由两个原子操作组成,但这两个原子操作直接串行,不加以同步,这组操作仍是非线程安全的,因为在这两个原子操作中可能有其他线程修改了 AtomicReference 指向的值,这破坏了不变性条件。

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

加锁机制

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

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

在程序 2-6 中使用 synchronized 修饰 service 方法,在同一时刻只能有一个线程可以使用 service 方法,服务的响应性非常低。

/*
* 2-6 能正确地缓存最新的计算结果,但并发性非常糟糕(不要这么做)
* 线程安全
* */
public class SynchronizedAdderServlet {
    private Integer lastNumber;
    private Integer lastResult;

    public synchronized void service(Request request, Response response){
        print();
        if(request.equals(lastNumber)){
            response.setValue(lastNumber);
        }else{
            lastNumber = request.getValue();
            response.setValue(request.getValue() + 6);
            lastResult = response.getValue();
        }
    }

    public void print(){
        System.out.println("lastNumber: " + lastNumber + "\t lastResult: " + lastResult);
    }

    public static void main(String[] args) {
        SynchronizedAdderServlet servlet = new SynchronizedAdderServlet();
        Response response = new Response();
        for(int i = 0; i < 50; i++){
            new Thread(() -> {
                servlet.service(new Request((int)(Math.random() * 100)), response);
            }).start();
        }
    }
}

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

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

/*
* 2-7 如果内置锁是不可重入的,这段代码会发生死锁
* 线程安全
* */
class Widget{
    public synchronized void doSomething(){
        System.out.println("Widget::doSomething()");
    }
}
public class LoggingWidget extends Widget{
    @Override
    public synchronized void doSomething() {
        System.out.println("LoggingWidget::doSomething()");
        super.doSomething();
    }

    public static void main(String[] args) {
        LoggingWidget widget = new LoggingWidget();
        widget.doSomething();
    }
}

用锁来保护状态

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

访问共享状态的复合操作都必须是原子操作以避免产生竞态条件。如果复合操作在执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到同步代码块中是不够的。如果使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。一种常见的错误认为:只有在写入共享变量时才需要同步,然而并非如此。(见3.1节)

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

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如 Vector 和其他同步集合类。

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

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

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

活跃性与性能

在 UnsafeCachingAdderServlet 中,我们引入了缓存来提升性能,在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用 SynchronizedAdderServlet 中的同步方式,那么代码的执行性能将会十分糟糕。它通过 Servlet 对象的内置锁来保护每一个状态变量,这种简单且粗粒度的方法能保证线程安全性,但读出的代价很高。由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行,这背离了Servlet 框架的初衷,即 Servlet 需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。

程序2-8中将 Servlet 的代码修改为两个独立的代码块,第一个代码块执行“先检查后执行”序列,另一个代码块负责对缓存更新。

/*
* 2-8 缓存最近计算的数值积计算结果的Servlet
* 线程安全
* */
public class CachedAdderServlet {
    private Integer lastNumber;
    private Integer lastResult;
    private long hits;
    private long cacheHits;

    public synchronized long getHits(){
        return hits;
    }

    public synchronized double getCacheHitRatio() {
        return (double)cacheHits / (double)hits;
    }

    public void service(Request request, Response response){
        //不要把从 request 提取数值等耗时操作放在同步代码块中
        int num = request.getValue();
        int result = Integer.MIN_VALUE;

        //判断是否命中缓存
        synchronized (this){
            ++hits;
            if(lastNumber != null && num == lastNumber){
                ++cacheHits;
                result = lastResult;
            }
        }
        //没有命中缓存就更新缓存的值
        if(result == Integer.MIN_VALUE){
            //计算结果
            result = request.getValue() + 6;
            synchronized (this){//更新缓存
                lastNumber = request.getValue();
                lastResult = result;
            }
        }
        response.setValue(result);
    }

    public static void main(String[] args) {
        CachedAdderServlet servlet = new CachedAdderServlet();
        Response response = new Response();
        for(int i = 0; i < 10; i++){
            new Thread(() -> {
                servlet.service(new Request((int)(Math.random() * 4)), response);
                System.out.println("Cache Hit Ratio: " + servlet.getCacheHitRatio());
            }).start();
        }
    }
}

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

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

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

对象的共享

要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。我们已经知道同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字 synchronized 只能用于实现原子性。同步还有一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。

可见性

可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。有多个读线程和写线程同时对一个变量进行操作,读线程可能读到的是过期的数据,我们无法确保读线程能适时地看到其他线程写入的值,有时甚至是不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步。

程序 3-1 中说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程和读线程都将访问共享变量 ready 和 number。主线程启动读线程,然后将 numer 设为 42,并将 ready 设为 true。读线程一直循环知道发现 ready 的值变为 true 然后输出number。虽然看起来可能会输出 42(运行了好多次,都是 42。。),但事实上很可能输出 0 ,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的 ready值和 number 值对于读线程来说是可见的。

/*
* 3-1 在没有同步的情况下共享变量
* 非线程安全
* */
public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread{
        @Override
        public void run() {
            while(!ready){
                //暂停当前正在执行的线程对象(及放弃当前拥有的cpu资源),并执行其他线程
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

NoVisibility 可能会输出 0 ,因为读线程看到了 ready 的值,但没有看到 number 的值,这种现象称为重排序(Reordering)。

在没有同步的情况下,编译器、处理器以及运行时都可能对操作的执行顺序进行意向不到的调整。只要有数据在多个线程之间共享,就使用正确的同步。

NoVisibility 展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看 ready 变量时,可能会得到一个已经失效的值。更糟糕的是,可能获得一个变量的最新值而获得另一个变量的失效值。失效数据还可能导致一些令人困惑的故障,例如意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环等。

程序 3-2 中的 MutableInteger 不是线程安全的,get 和 set 都是在没有同步的情况下访问 value的。

/*
* 3-2 非线程安全的可变整数类
* */
public class MutableInteger {
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

程序 3-3 SynchronizedInteger 通过对 get 和 set 方法进行同步,可以使之成为一个线程安全的类。仅对 set 方法进行同步是不够的,调用 get 的线程仍然会看见失效值。

/*
* 3-3 线程安全的可变整数类
* */
public class SynchronizedInteger {
    private int value;

    public synchronized int getValue() {
        return value;
    }

    public synchronized void setValue(int value) {
        this.value = value;
    }
}

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。

最低安全性适用于绝大多数变量,但有一个例外:非 volatile 类型的 64 位数值变量(double 和 long)。Java 内存模型要求,变量的读取和写入操作必须是原子操作,但对于非 volatile 类型的 long 和 double 变量,JVM 允许将64 位的读写操作分为两个 32 位的操作。当读取到一个新值的高 32 位 和 旧值的低 32 位组合的 64 位数时,就出现了错误。

在多线程程序中使用共享且可变的 long 和 double 等类型的变量也是不安全的,除非用 volatile 来声明它们,或者用锁保护起来。

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。当后一个线程执行由锁保护的同步代码块时,可以看到前一个线程之前在同一个同步代码块中的所有操作结果。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。

在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 synchronized 关键字更轻量级的同步机制。从内存可见性的角度来看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量相当于进入同步代码块。

仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用 volatile 变量。

程序 3-4 给出了 volatile 变量的一种典型用法:检查某个状态标记以判断是否退出循环。

/*
* 3-4 数绵羊
* */
volatile boolean asleep;
...
    while(!asleep)
        countSomeSheep();

volatile 的语义不足以保证递增操作的原子性(count),除非你能确保只有一个线程对变量执行写操作。(如果存在两个线程对变量进行写操作,需要一种机制来保持这两个线程之间的互斥关系,但 volatile 只能保证可见性,不能保证原子性)

加锁机制既可以确保可见性又可以保证原子性,volatile 变量只能保证可见性。

当且仅当满足以下所有条件时,才应该使用 volatile 变量:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量时不需要加锁

发布与逸出

“发布(Publish)”一个对象指,使对象能够在当前作用域之外的代码中使用。“逸出(Escape)”指,当某个不应该发布的对象被发布。

发布对象最简单的方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。如 3-5 所示。

/*
* 3-5 发布一个对象
*/
public static Set<Secret> knownSecrets;
public void initialize(){
    knownSecrets = new HashSet<Secret>();
}

当发布某个对象时,可能会间接地发布其他对象。如果将一个 Secret 对象添加到集合 knownSecrets 中,那么同样会发布这个对象,因为任何代码都能遍历这个集合,获得Secret 对象的引用。

/*
* 3-6 使内部的可变状态逸出(不要这么做)
* */
class UnsafeStates{
    private String[] states = new String[]{
        "AK", "AL" ...
    };
    
    public String[] getStates(){return states; }
}

上述代码中,任何调用者都能修改 states 数组的内容,数组states已经逸出了它所在的作用域。

当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。

最后一种发布对象或其内部状态的机制就是发布一个内部类的实例。3-7 中,当 ThisEscape 发布 EventListener 时,也隐含地发布了 ThisEscape 实例本身,因为在这个内部类实例中包含了对 ThisEscape 实例地隐含引用。

/*
* 3-7 隐式地使 this 引用逸出(不要这么做)
* */
public class ThisEscape{
    public ThisEscape(EventSource source){
        source.registerListener(new EventListener(){
            public void onEvent(Event e){
                doSomething(e);
            }
        });
    }
}

在 ThisEscape 中给出了逸出地一个特殊示例,即 this 引用在构造函数中逸出。当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。在构造过程中使 this 引用逸出的一个常见错误是:在构造函数中启动一个线程。如果想在构造函数中注册一个时间监听器或启动线程,可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程。

/*
* 3-8 使用工厂方法来防止 this 引用在构造过程中逸出
* */
public class SafeListener{
    private final EventListener listener;
    
    private SafeListener(){
        listener = new EventListener(){
            public void onEvent(Event e){
                doSomething(e);
            }
        };
    }
    
    public static SafeListener newInstance(EventSource source){
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果尽在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement)。

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问。

/*
* 3-9 基本类型的局部变量与引用变量的线程封闭性
*/
public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        // animals 被封闭在方法中,不要使它们逸出
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a))
                candidate = a;
            else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }

如果发布了对 animals 的引用,那么线程封闭性将被破坏,并导致对象 animals 的逸出。

维持线程封闭性的一种更规范方法是使用 ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。

例如,在单线程程序中可能维持一个全局的数据库连接,并在程序启动时初始化这个连接,由于 JDBC 连接对象不一定是线程安全的。通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接。

/*
* 3-10 使用 ThreadLocal 来维持线程封闭性
* */
class Connection{

}

public class ThreadLocalDemo {
    private static ThreadLocal<Connection> connectionHandler= new ThreadLocal<Connection>(){
        @Override
        protected Connection initialValue() {
            return new Connection();
        }
    };

    public static Connection getConnection(){
        //当第一次调用 get 方法时,initialValue 将会被调用
        return connectionHandler.get();
    }
}

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。

ThreadLocal 变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。

不变性

满足同步需求的另一种方法是使用不可变对象(Immutable Object)。如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

不可变对象一定是线程安全的。

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态不能改变
  • 对象的所有域都是 final 类型
  • 对象是正确创建的(在创建对象期间 this 引用没有逸出)

在不可变对象的内部仍可以使用可变对象来管理它们的状态,如程序 3-11 所示,但是其中的 Set 对象在构造完成之后无法对其进行修改。

/*
* 3-11 在可变对象基础上构建的不可变类
*/ 
public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<String>();

    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }

    public boolean isStooge(String name) {
        return stooges.contains(name);
    }

    public String getStoogeNames() {
        List<String> stooges = new Vector<String>();
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
        return stooges.toString();
    }
}

关键字 final 可以视为 C++ 中 const 机制的一种受限版本,用于构造不可变对象。final 类型的域是不能修改的(但如果 final 域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。final 域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。

正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为 final 域”也是一个良好的编程习惯。

我们看一个因式分解 Servlet,它包含两个原子操作:更新缓存的结果,以及判断缓存中的数值是否等于请求的数值。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据。例如 3-12 的 OneValueCache。

/*
* 3-12 对数值及其因数分解结果进行缓存的不可变容器类
*/
public class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i,
                         BigInteger[] factors) {
        lastNumber = i;
        //如果没有调用 copyOf 函数,那么 OneValue 就是不可变的
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}

对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。

程序 3-13 中的 VolatileCachedFactorizer 使用了 OneValueCache 来保存缓存的数值及其因数。当一个线程将 volatile 类型的 cache 设置为引用一个新的 OneValueCache 时,其他线程就会立即看到最新缓存的数据。

/*
* 3-13 使用指向不可变容器对象的 volatile 类型引用以缓存最新的结果
*/
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
    private volatile OneValueCache cache = new OneValueCache(null, null);

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            //更新缓存
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn‘t really factor
        return new BigInteger[]{i};
    }
}

安全发布

在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。如果像程序 3-14 那样将对象引用保存到公有域中,那么还不足以安全地发布这个对象。

/*
* 3-14 在没有足够同步的情况下发布对象(不要这么做)
*/
public class StuffIntoPublic {
    public Holder holder;

    public void initialize() {
        holder = new Holder(42);
    }
}

由于存在可见性问题,其他线程看到的 Holder 对象将处于不一致的状态,即使该对象的构造函数中已经正确地构造了不变性条件。这种不正确地发布将导致其他线程看到尚未创建完成的对象

/*
* 3-15 由于未被正确发布,因此这个类可能出现故障
*/
public class Holder {
    private int n;

    public Holder(int n) {
        this.n = n;
    }

    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }
}

由于没有使用同步来确保 Holder 对象对其他线程可见,因此将 Holder 称为“未被正确发布”。除了发布对象的线程外,其他线程可以看到的 Holder 域是一个失效值,因此将看到一个空引用或之前的旧值。

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。

在没有额外同步地情况下,也可以安全地访问 final 类型的域。然而,如果 final 类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态是仍然需要同步。

要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到 volatile 类型的域或者 AtomicReference 对象中。
  • 将对象的引用保存到某个正确构造对象的 final 类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象叫做“事实不可变对象(Effectively Immutable Object)”。

在没有额外同步的情况下,任何线程都可以安全得使用被安全发布得事实不可变对象。

如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态得可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保继续修改操作的可见性。

对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布
  • 事实不可变对象必须通过安全方式发布
  • 可变对象必须通过安全方式发布,并且必须是线程安全的或者由某个锁保护起来

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

  1. 线程封闭。线程封闭的对象只能由一个而线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  2. 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
  3. 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步同步。
  4. 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

对象的组合

Java并发编程实战

标签:tst   不能   统计   支持   一个   基本   出现   循环   内存操作   

原文地址:https://www.cnblogs.com/hoo334/p/14037787.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!