Java并发编程实战(基础篇)

学习本文内容,默认已经了解Java多线程基础。

本篇即为基础篇,那么主要讨论如何编写线程安全的代码,大概分为3个方向

  1. 如何避免多线程同时访问同一时刻访问相同数据
  2. 共享、发布对象,能够安全地由多个线程同时访问
  3. 根据现有线程安全组件构建线程安全的类

在学习各模块只是之前,先普及一些知识。

1.编写线程安全的代码核心?

在访问共享、可变的状态要进行正确的管理。(可能现在还不太明白,看下去慢慢就懂了)

2.什么是线程安全性?

安全性是一个代码上使用的术语,但它只与状态相关,应用于封装其状态的整个代码,可能是一个线程安全的类,也可能是一个线程安全的程序。
(当多个线程访问某个类时,不管运行时环境采用何种调度方式或这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类能够表现出正确的行为,那么这个类就是线程安全的类,这段代码也就具有线程安全性)

3.一个无状态的对象一定是线程安全的
public class Test extends Thread {
    @Override
    public void run() {
        AtomicInteger integer = new AtomicInteger(0);
        int i = integer.incrementAndGet();
        System.out.println(i);
    }
}

这段代码计算过程的变量的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。

一、使用同步避免多线程同一时刻访问相同数据

大部分多线程同一时刻访问相同数据,都会产生原子性问题。

1.什么是原子性?

原子性在一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

没有同步的情况下统计线程执行次数:

	static int count = 0;
    @Override
    public void run() {
        AtomicInteger integer = new AtomicInteger(0);
        integer.incrementAndGet();
        for (int i = 0; i < 10000; i++) {
            ++count;
        }
    }

相信这个问题,已经不止一次看到过了,很明显 ++count 这个并非一个原子性操作,这是一个“读取 - 修改 - 写入”的操作,在多线程的情况下就会出现下面的线程问题:
在这里插入图片描述在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,叫做:竞态条件。

1.1 竞态条件

多个线程对同一个变量或资源进行读写的时候出现竞态条件,最终结果取决于多个线程的交替执行时序。
最常见的竞态条件类型是 “先检查后执行” 操作,通过一个可能失效的观测结果决定下一步的动作。

延迟初始化中的竞态条件(单例模式懒汉式加载):

public class Test {

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

可能会破坏一个类的正确性,存在下面的线程问题,如果 instance 初始化需要较长时间,那么就会增加程序运行负担。
在这里插入图片描述
具体问题以及解决方法参考:Java设计模式之单例模式

在这里提一下,在使用双重检查锁定(DCL)来解决延迟初始化的线程问题时,需要将实例定义为 volatile 变量。
这里涉及到了Java内存模型问题,该问题会在后面的Java并发编程实战(高级篇)讲述,可以移步查看。

2.2 复合操作(存在竞态条件的操作)

根据上面的竞态条件问题类型,为了确保线程安全性,“先检查后执行”(延迟初始化)、“读取-修改-写入”(递增运算)等操作必须是原子性的,那么如何保证复合操作的原子性呢?

  1. 使用并发包JUC提供的一些并发容器或原子类(后面进阶篇会讲解)
  2. 加锁

下节,讲解加锁,在JUC的atomic包下提供了原子类,这些原子类保证了一些复合操作为原子性。

使用 AtomicInteger 类型的变量来统计线程执行次数:

	private static final AtomicInteger count = new AtomicInteger(0);

    @Override
    public void run() {
        AtomicInteger integer = new AtomicInteger(0);
        integer.incrementAndGet();
        for (int i = 0; i < 10000; i++) {
            count.incrementAndGet();
        }
    }

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

2.用锁来保护状态

在接触多线程时就学到了使用 内置锁(synchronized) 来实现线程同步,还可以使用显式锁等等,这里就不一一列举,下面讲一下一个类的状态在什么情况下,需要使用锁来保护其状态:

  1. 对于可能被多个线程同时访问的可变状态变量,在访问这个变量的所有位置都需要同步,并且都需要持有同一个锁;
  2. 每个共享的和可变的变量都应该只有一个锁来保护,从而使维护人员知道哪一个锁;
  3. 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。(不变性条件简单理解就是两个或多个操作结果之间存在着某种固定的关系,不能出现顺序错误问题,这也就说明了不变性条件必须同步)

下面代码希望提高Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无需重新计算:

public class UnsafeCachingFactorizer implements Servlet{
    
    private final AtomicReference<Integer> lastNumber = 
    			new AtomicReference<>();
    private final AtomicReference<Integer[]> lastFactors = 
    			new AtomicReference<>();
    
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber))
            encodeIntoResponse(resp, lastFactors.get());
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, lastFactors.get());
        }
    }
}

UnsafeCachingFactorizer 不变性条件是:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值。
很明显这段代码可能会破坏这个不变性条件,在线程A获取两个值的过程中,线程B可能修改了它们,这样线程A的不变性条件就被破坏了。(存在“先检查后执行”的竞态条件)

那么我们采用内置锁来解决:

public class SynchronizedFactorizer implements Servlet{

    private AtomicReference<Integer> lastNumber;
    private AtomicReference<Integer[]> 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.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, lastFactors.get());
        }
    }
}

3.活跃性和性能

UnsafeCachingFactorizer的流程图:
在这里插入图片描述
这是一个不良的并发应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。

这样虽然解决了同步问题,但并发性却非常差,因为 Servlet 设计初衷是希望能够同时处理多个请求的,而在 service 方法上加锁,使得 Servlet 每次只能处理一个线程请求,这在负载过高的情况下将给用户带来糟糕的体验。

那么怎样遇到这种情况怎样解决呢?
将不影响共享状态且执行时间较长、阻塞的操作(例如:网络IO、控制台IO等)从同步代码块中分离出去,这样既确保了 Servlet 的并发性,同时又维护了线程安全性。

使用了同步代码块之后的 Servlet:

public class SynchronizedFactorizer implements Servlet{

    private BigInteger lastNumber;
    private BigInteger[] lastFactors;

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

这里使用两个同步代码块,其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作的代码,另一个同步代码块则负责确保对缓存的数值合因数分解结果进行同步更新。

二、共享\发布对象,能够安全地由多个线程同时访问

上一节主要讲解了使用同步来避免多线程同时访问相同数据,重点在于通过同步使某个操作变为原子性,但其实同步还有一个另一个重要方面:内存可见性我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化,同步势必会降低并发的效率,本节后半部分会讲解如何使用一些方法来安全发布一个对象,从而提高程序处理并发的能力。

1.可见性

在单线程环境中,如果像某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。那如果读操作和写操作在不同的线程中执行时,情况并非如此。通常我们无法确保执行读操作的线程能实时的看到其他线程写入的值。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制

在没有同步情况下共享变量:

public class NoVisbility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready){
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    private static class WriterThread extends Thread {
        @Override
        public void run() {
            ready = true;
            number = 44;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new ReaderThread().start();
        new WriterThread().start();
    }
}

让 WriterThread 线程修改共享变量 ready 和 number 的值,在线程 ReaderThread 中,如果ready 为 false ,那么就让出执行权(多线程重新抢占),否则打印 number

取决于JDK的版本,NoVisbility 可能会持续循环下去,因为读线程可能无法看到 ready 的值(两个线程保存 ready 变量的副本,一个线程修改了,另外一个线程没有接受到,就无法的到正确数据 - 这里涉及到了Java内存模型);也可能输出0,这是 “重排序” 情况(在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些优化调整,在 Java并发编程高级篇 会详细讲解)。

1.1 加锁与可见性

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。

同步的可见性保证流程图:
在这里插入图片描述

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

1.2 Volatile变量

这里简单介绍一下:Java语言提供了一种稍弱的同步机制,即 Volatile 变量,用来确保将变量的更新操作通知到其他线程。

当变量声明为 Volatile 类型后 ,编译器与运行时会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起 “重排序”(Java并发编程高级篇会详细讲解 Volatile 语义)。

Volatile 变量对可见性的影响比 Volatile 变量本身更重要,仅当 Volatile 变量能简化代码的实现以及对同步策略的验证时,才能使用。
不建议过度依赖 Volatile 变量提供的可见性。

用于检查某个状态标记以判断是否退出循环,通常和 CAS 操作一块使用,上面代码篇中的 ready 变量就可以改为:

private volatile static boolean ready;

这样就可以保证变量对线程的可见性,但还是不能解决线程同步问题(这也间接说明了 Volatile 不能保证原子性)。

使用 Volatile 变量需要符合下面所有条件:

  • 对变量的写入操作不依赖于当前变量的值,或者能够保证单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中

加锁机制即可以确保可见性又可以确保原子性,而 Volatile 变量只能确保可见性。
这里简单了解一下 Volatile 的用法,在高级篇会详细讲解 Volatile 变量。

2.发布与逸出

“发布”:一个对象的意思是值,使对象能够在当前作用域之外的代码中使用(这个对象可以是类中的任意成员变量),例如将对象的引用保存到其他代码可以访问的地方,或者在非私有的方法中返回对象的引用,或者将对象的引用传递给其他类的方法。
在许多情况中,我们要确保对象及其内部状态不被发布,发布内部状态会破坏封装性,降低程序性能。

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

public class UnsafeDemo {
    public static List<String> list = new ArrayList<>();

    public List<String> getList() {
        return list;
    }
}

当发布 list 这个对象的同时,也间接发布了其他对象。如果将一个String对象添加到集合,那么同时也会发布这个对象。

“逸出”:当某个对象不应该发布的对象被发布出去。

隐式地使 this 引用逸出:

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
    
    void doSomething(Event e){}
    
    interface EventSource {
        void registerListener(EventListener e);
    }
    
    interface EventListener{
        void onEvent(Event e);
    }
    
    interface Event{}
}

在这个例子里面,当我们实例化 ThisEscape 对象时,会调用 source 的 registerListener 方法,这时便启动了一个线程,而且这个线程持有 ThisEscape 对象(调用了 ThisEscape 的 doSomething 方法),但此时 ThisEscape 对象却还没有实例化完成,这边产生了 this 隐式地逸出。其他线程访问构造方法没有完成的对象,可能会造成意料之外的问题。

我们可以使用一个私有的构造方法和一个公共的工厂方法,来避免 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;
  }
 
  void doSomething(Event e) {
  }
 
  interface EventSource {
    void registerListener(EventListener e);
  }
 
  interface EventListener {
    void onEvent(Event e);
  }
 
  interface Event {
  }
 }

通过线程封闭不变性来确保对象及其内部状态不被发布。

3.线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据,当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,这是实现线程安全性、避免并发问题(线程之间没有共享可变变量或可变状态)最简单的方式之一
当然这种方式对象及其内部状态自然不会被发布。

事实上,对线程封闭对象的引用通常保存在公有变量中,当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。(大多数GUI框架都是单线程,一些可视化组件或数据模型也都将引用保存在公共变量中)

3.1 Ad-hoc 线程封闭

Ad-hoc 线程封闭描述了线程封闭的方式,由开发人员或从事该项目的开发人员确保仅在单个线程内使用此对象。 这种方式方法可用性不高,在大多数情况下应该避免。

Ad-hoc 线程封闭下的一个特例适用于 volatile 变量。 只要确保 volatile 变量仅从单个线程写入,就可以安全地对共享 volatile 变量执读 - 改 - 写操作。在这种情况下,您将修改限制在单个线程以防止竞争条件,并且 volatile 变量的可见性保证确保其他线程看到最新值。

3.2 栈封闭

在栈封闭中,只能通过局部变量才能访问对象,局部变量的固有属性之一就是封闭在执行线程中,栈封闭比Ad-hoc 线程封闭更易于维护,也更加健壮。

private long numberOfPeopleNamedJohn(List<Person> people) {
  	List<Person> localPeople = new ArrayList<>();
  	localPeople.addAll(people);
  	return localPeople.stream().filter(person -> person.getFirstName().equals("John")).count();
}

在上面的代码中,我们传递一个 person 对象的 list 但不直接使用它。 相反,我们创建自己的 list,该 list 是当前正在执行的线程的本地 list,并将变量 people中的所有 person 添加到 localPeople。由于我们仅在 numberOfPeopleNamedJohn方法中定义列表,这使得变量localPeople 受到堆栈隔离保护,因为它只存在于一个线程的堆栈上,因此任何其他线程都无法访问它。这使得 localPeople 线程安全。

不应该让 localPeople 的作用于超过这个方法的范围,以保证堆栈的隔离控制。在定义这个变量时,应该记录或注释为什么要定义这个变量,通常,只有在当前开发人员的脑海中才不让它超出方法的作用域,但是在将来,另一个开发人员可能会不知道为何如此设计而陷入困境。

3.3 ThreadLocal类

维持线程访问封闭性的一种更规范方法是使用 ThreadLocal,这个类能够使线程的某个值与保存值的对象关联起来。它允许我们为不同的线程存储不同的对象,并维护哪个对象对应于哪个线程。它有 set 和 get 方法,这些方法为使用它的每个线程维护一个单独的 value 副本。get() 方法总是返回从当前正在执行的线程传递给 set()的最新值。

因为JDBC的连接对象不一定是线程安全的,那么当多线程在没有同步的情况下使用全局的连接对象,就不是线程安全的。
使用 ThreadLocal 为每一个线程保存属于自己的JDBC连接对象:

private static ThreadLocal<Connection> connectionHolder
            = new ThreadLocal<>() {
        public Connection initialValue() {
            return DriverManager.getConnection(DB_URL);
        }
    };
    
    public static Connection getConnection() {
        return connectionHolder.get()
    }

ThreadLocal 详细解析:ThreadLocal

4.不变性

满足同步需求的另一种方法是使用不可变对象,之前讲过的都与多线程试图同时访问同一个可变的状态相关,如果一个对象的状态不会改变,那么这些问题也就不会出现了。

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

不可变对象只有一种状态,并且该状态由构造函数来控制。

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

  • 对象创建以后其状态就不能修改;
  • 对象的所有域都是 final 类型;
  • 对象是正确创建的(在对象的创建期间,this 引用没有逸出 - 高级篇讲解 final this逸出的问题)

4.1 final 域

final 类型的域是不能修改的,但如果 final 域所引用的对象是可变的,那么这些被引用的对象是可以修改的。

因为 final 域有着特殊的语义,final 域可以确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步

在一个可变对象的某些域声明为 final 类型,可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象的可能的状态。

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

4.2 示例:使用 Volatile 类型发布不可变对象

不可变对象能提供一种弱原子性。

public class UnsafeCachingFactorizer implements Servlet{
    
    private final AtomicReference<Integer> lastNumber = 
    			new AtomicReference<>();
    private final AtomicReference<Integer[]> lastFactors = 
    			new AtomicReference<>();
    
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber))
            encodeIntoResponse(resp, lastFactors.get());
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, lastFactors.get());
        }
    }
}

这是我们之前尝试使用两个 AtomicReference 变量来保护罪行的数值及其因数分解的结果,但由于 不变性条件 的问题并非是线程安全的,因为我们无法以原子的方式同时读取或更新这两个相关的值。同样使用 Volatile 类型的变量来保存这些值也不能做到原子性。

因数分解 Servlet 将执行两个原子操作:更新缓存结果、判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解的结果。

我们可以考虑创建一个不可变的类来包含这些数据,这些数据的操作也会变成原子操作。

一个对数值及其因数分解结果进行缓存的不可变容器类:

class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i, BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

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

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

需要注意的是:如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致性的状态。
如果新创建的容器对象的引用没有使用 Volatile 类型,那么就不能保证其他持有原有对象的线程缓存到最新的数据,这样就会出现失效数据。

使用指向不可变容器对象的 Volatile 类型引用来缓存最新的结果:

public class VolatileCachedPactorizer 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);
    }
}

通过将竞态条件以及多个状态变量维持不变性条件封装在不可变容器对象中,并使用一个 Volatile 类型的引用来确保可见性,使得 VolatileCachedPactorizer 在没有显式地加锁的情况下仍然是线程安全的。

5.安全发布

上面我们重点讨论了如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然在某些情况下我们希望在多个线程间共享这个对象,此时必须确保安全地进行共享。

5.1 不正确的发布:正确的对象被破坏

下面类对象未正确发布,因此这个类可能出现故障(人为抛出):

public class Holder {
    private int n;

    public Holder(int n) {
        this.n = n;
    }
    
    public void assertSanity() {
        if (n != n)
            throw new AssertionError();
    }
}

上面代码可能因为 “重排序” 的原因导致某个线程在第一次读取域中得到失效值,而再次读取这个域时会得到有个更新值。

问题不在于 Holder 类本身,而在于 Holder 类的对象没有正确发布,导致类在初始化出现错误。如果将 n 声明为 final 类型,那么 Holder 将不可变,从而避免了不正确发布的问题。

5.2 不可变对象与初始化安全性

因为Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。

在没有额外同步的情况下,也可以安全地访问 final 类型的域。如果 final 类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
(这里可以参考4.2示例,由于 OneValueCache 不可变容器所存储的变量是可变的,所以使用了弱同步 Volatile 类型来进行同步,可能有点牵强)

5.3 安全发布可变对象

可变对象必须通过安全的方式来发布,这通常意味着发布和使用该对象的线程都必须使用同步。

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

  • 在静态初始化函数中初始化一个对象引用;
  • 将对象的引用保存到 volatile 类型的域或者 AtomicReference 对象中;
  • 将对象的引用保存到某个正确构造对象的 final 类型域中;
  • 将对象的引用保存到一个由锁保护的域中(将对象放入某个线程安全的容器就满足这一条)

三、根据现有线程安全组件构建线程安全的类

前两节合在一起,就形成了构建线程安全类以及通过 JUC 类库来构建并发应用程序的重要基础。

大多数对象都是组合对象。当从头开始创建一个类,或者将多个非线程安全的类组合为一个类时,我们需要对每一次内存访问进行分析以确保程序是线程安全的。但是,如果类中的各个组件都已经是线程安全的,那会是构建线程安全类的一个良好的开端。

1.线程安全性的委托

委托是创建线程安全类的一个最有效的策略:只需让现有线程安全类管理所有的状态即可。

1.1 示例:基于监视器模式的车辆追踪

Java监视器模式:把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。

一个用于调度车辆的 “车辆追踪器”,每台车都由一个 String 对象来标识,并且拥有一个相应的位置坐标(x,y),该对象可以由一个视图线程和多个执行线程共享。
视图线程读取车辆的名字和位置,并显示在界面;执行更新线程通过GPS从GUI界面输入数据来修改车辆的位置:

Collections.unmodifiableMap(Map<? extends K, ? extends V> m)
会返回一个只读但可修改的 Map 集合,
并且修改 m 的元素也会改变返回的 Map 中的元素,这是一种深拷贝
public class MonitorVehicleTracker {
	private final Map<String, MutablePoint> locations;
	
	public MonitorVehicleTracker (Map<String, MutablePoint> locations) {
		this.locations = deepCopy(locations);
	}
	
	public synchronized Map<String, MutablePoint> getLocations () {
		return deepCopy(locations);
	}
	
	public synchronized MutablePoint getLocation (String id) {
		MutablePoint loc = locations.get(id);
		return loc == null ? null :  new MutablePoint(loc);
	}
	
	public synchronized void setLocation (String id, int x, int y) {
		MutablePoint loc = locations.get(id);
		if (loc == null) {
			throw new IllegalArgumentException("No such ID:" + id);
		}
		loc.x = x;
		loc.y = y;
	}
	
	private static Map<String, MutablePoint> deepCopy (Map<String, MutablePoint> m) {
		Map<String, MutablePoint> result = new HashMap<String, MutablePoint>();
		for (String id : m.ketSet()) {
			result.put(id, new MutablePoint(m.get(id)));
		}
		return Collections.unmodifiableMap(result);
	}
}
public class MutablePoint {
	public int x, y;
	
	public MutablePoint () {
		x = 0;
		y = 0;
	}
	public MutablePoint (MutablePoint p) {
		this.x = p.x;
		this.y = p.y;
	}
}

虽然类 MutablePoint 不是线程安全的,但追踪器类是线程安全的。

这种实现方式是通过在返回客户代码之前复制可变的数据来维持线程安全的,通常情况下,并不存在性能问题,但在车辆容器非常大的情况下将极大地降低性能。此外,每次代用 getLocation 方法复制数据,不能实时更新车辆位置信息。
这种情况也是取决于需求。
如果你需要频繁的知道每个像素点的信息 那么这就是缺点;如果保证集合上的一致性需求 这就是优点。

1.2 示例:基于委托的车辆追踪器

上面说了那么多,现在介绍一个实际的委托示例。

我们将车辆的位置保存到一个 Map 对象中,因此需要一个线程安全的 Map 类 - ConcurrentHashMap(现在只需要知道是一个线程安全的类,进阶篇会讨论)

在 DelegatingVehicleTracker 中使用的不可变 Point 类:

public class Point {
	public final int x, y;
	public Point (int x, int y) {
		this.x = x;
		this.y = y;
	}
}
由于Point类是不可变的,因而它是线程安全的。
不可变的值可以被自由地共享与发布,因此在返回location时不需要复制。

将线程安全委托给 ConcurrentHashMap:

public class DelegatingVehicleTracker {
	private final ConcurrentMap<String, Point> locations;
	private final Map<String, Point> unmodifiableMap;
	
	public DelegatingVehucleTracker (Map<String, Point> points) {
		locations = new ConcurrentHashMap<String, Point>(points);
		unmodifiableMap = Collections.unmodifiableMap(locations);	
	}
	
	public Map<String, Point> getLocations () {
		return unmodifiableMap;
	}
	
	public Point getLocation (String id) {
		return locations.get(id);
	}
	
	public void setLocation (String id, int x, int y) {
		if (locations.replace(id, new Point(x, y)) == null) {
			throw new IllegalArgumentException ("invalid vehicle name:" + id);
		}
	}
}

如果使用最初的 MutablePoint 类而不是 Point 类,就会破坏封装性,因为 getLocations 会发布一个指向可变状态的引用,而这个引用不是线程安全的。

在使用监视器模式的车辆追踪器中返回的是车辆位置的快照;
在委托的车辆追踪器中返回的是一个不可修改但却实时的车辆位置视图。

如果需要一个不发生变化的车辆视图,那么 getLocations 可以返回对 locations 这个 Map 对象的一个浅拷贝,返回 locations 的静态拷贝而非实时拷贝:

public Map<String, Point> getLocations () {
	return Collections.unmodifiableMap(
			new HashMap<String, Point>(locations));
}

1.3 示例:发布状态的车辆追踪器

上面我们将 point 封装为不可变对象,这样使得调用者无法修改返回的 point 的值来改变车辆的位置,不够灵活,所以我们可以考虑将 point 类的状态发布出去。

如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。

Point 记录车的位置,即使一个类在某一时刻修改了这个值,也并没有破坏任何不变性条件。

可变且线程安全的 Point 类

public class SafePoint {
    private int x, y;
    
    private SafePoint (int[] a) {
        this(a[0], a[1]);
    }
    
    public SafePoint (SafePoint p) {
        this(p.get());
    }
    
    public SafePoint (int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    synchronized public int[] get() {
        return new int[]{x, y};
    }
    
    synchronized public void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

想要得到坐标点的值,只是会得到一个副本

return new int[]{x, y};

PublishingVehicleTracker 将其线程安全性委托给底层的 ConcurrentHashMap,只是 Map 中的元素时线程安全的且可变的 Point,而并非不可变的:

public class PublishingVehicleTracker {
	private final Map<String, SafePoint> locations;
	private final Map<String, SafePoint> unmodifiableMap;
	
	public PublishingVehicleTracker (Map<String, SafePoint> locations) {
		this.locations 
			= new ConcurrentHashMap<String, SafePoint>(locations);
		this.unmodifiableMap
			= Collections.unmodifiableMap(this.locations);	
	}
	
	public Map<String, SafePoint> getLocations () {
		return unmodifiableMap;
	}
	
	public SafePoint getLocation (String id) {
		return locations.get(id);
	}
	
	public void setLocation (String id, int x, int y) {
		if (!locations.containsKey(id)) {
			throw new IllegalArgumentException("invalid vehicle name:" + id);
		}
		locations.get(id).set(x, y);
	}
}

getLocation 方法返回底层 Map 对象的一个不可变副本,调用者不能增加或删除车辆,但却可以通过修改返回 Map 中的 SafePoint 的值来改变车辆的位置(但由于会 get 到 SafePoint 的副本,所以结果只能调用线程看到)。

在 PublishingVehicleTracker 类中,并没有涉及到关于 SafePoint 对象的竞态条件或复合操作,那么这个类是线程安全的。那如果需要对 SafePoint 进行一些“先检查在执行”的类似操作,那么 PublishingVehicleTracker 类中采用的方法显示是不合适的。

发布了120 篇原创文章 · 获赞 16 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_43327091/article/details/104135934