美文网首页
《Java并发编程实战》 - 基础知识②

《Java并发编程实战》 - 基础知识②

作者: MeazZa | 来源:发表于2018-08-09 14:05 被阅读0次

    在上一篇文章中,我们讨论了如何通过同步来避免多个线程在同一时刻访问同一数据,本文中我们将继续介绍如何共享和发布对象。

    1 可见性

    之前我们重点讨论的是操作的原子性,这里我们继续讨论的同步中的另外一个重要的方面:内存可见性。

    @BadCode
    public class NoVisibility {
      private static boolean ready;
      private static int number;
    
      private static class ReaderThread extends Thread {
        public void run() {
          while (!ready)
            Thread.yield();
          System.out.println(number);
        }
      }
    
      public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
      }
    }
    

    这段代码由主程序启动读程序,然后将number设为42,并将ready设为true。读线程一直循环直到发现ready的值变成true,然后输出number的值。虽然NoVisibility看起来会输出42,但事实上很可能输出0,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,无法保证主线程写入的ready值和number值对于读线程来说是可见的。

    一种更奇怪的现象是,NoVisiblity可能会输出0,也就是读线程看到了ready的值,却没有看到number的值,这种现象被称为“重排序(Recordering)”。在没有同步的情况下,编译器、处理器以及运行时都可能对操作的执行顺序进行一些意想不到的调整。

    在实际的多线程执行中,可能会出现读取到失效数据的情况,一个线程可能获得某个变量的最新值,而另一个线程获取的是失效值。失效值可能会导致程序出现严重的安全问题或活跃性问题。

    上述情况虽然会出现读取到失效值的情况,但至少这个值是由之前某个线程设置的,而不是一个随机值。这种安全性保证也被称为最低安全性。最低安全性适用于绝大多数变量,但存在一个例外,就是非violate类型的64位数据变量(double和long),这类变量被拆分为两个32位的操作,这样当对该变量的读和写在不同的线程中执行时,很可能会读取到某个值的高32位和另一个值的低32位,导致获取到之前不存在的数值结果。

    使用内置锁可以确保线程以可预测的方式查看另一个线程的执行结果。这里我们也能更好的理解上一篇文章中提到的,对于变量在各处的使用,需要使用同一个锁进行同步,这样才能避免一个线程在未持有正确锁的情况下,读取到失效值。

    • violate变量可以用于解决内存可见性的问题

    用violate修饰的变量会有以下两个特点:

    1. 不会和其他内存操作一起重排序
    2. 不会被缓存在寄存器或者对其他处理器不可见的地方

    这样就保证在读取violate类型的变量时,总会返回最新写入的值。

    violate变量是一种比synchronized关键字更轻量级的同步机制。从内存可见性的角度来看,写入violate变量相当于退出同步代码块,而读取violate变量相当于进入同步代码块。使用violate来控制可见性,通过比使用锁的代码更脆弱,也更难以理解。

    注意使用violate变量并不能保证类似count++这类递增操作的原子性,除非确保只有一个线程对变量执行写操作。

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

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

    2 发布和逸出

    发布一个对象,指其他类可以访问另一个类的对象中的一些变量。当某个不应该发布的对象被发布时,这种情况就被称为逸出。常见的逸出情况有以下几种:

    • 将指向该对象的引用保存到其他代码可以访问的地方
    • 在一个非private的方法返回该引用
    • 将引用传递到其他类的方法中

    比如以下的代码,任何调用者都可以修改这个数组的内容,那么states逸出了它所在的作用域。

    class UnsafeStates {
      private String[] states = new String[] {
        "AK", "AL" ...
      }
      public String[] getStates() { return states; }
    }
    

    无论其他的代码对已发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在。当某个对象逸出后,必须假设有某个类或线程可能会误用该对象。这正是封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。

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

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

    上一段代码的问题在于在构造函数中创建了内部类,并且发布了出去。
    可以采用以下工厂方法的方式来避免这个问题。

    public class SafeListener {
      private final EventListener listener;
    
      public 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;
      }
    }
    

    [扩展阅读]:Java并发编程——this引用逸出

    3 线程封闭

    之前讨论了关于如何通过同步正确地访问共享变量,一种避免使用同步的方式就是不共享数据,仅在单线程中访问数据,就不需要同步。这种技术就被称为线程封闭,它是实现线程安全性最简单的方法之一。

    在Java语言中并无法强制将对象封闭在线程中,因此线程封闭是程序设计的一个考虑因素,必须在程序中实线。常见的三种实现线程封闭的方法如下:

    • Ad-hoc线程封闭

    Ad-hoc线程封闭是指完全由程序来承担维护线程封闭性的职责。一种实现方式是当保证只有单个线程写入violate变量时,就可以安全地在这些共享的violate变量上执行“读取 - 修改 - 写入”的操作。这种封闭性往往是很脆弱的,应该尽量少使用。

    • 栈封闭

    栈封闭是只能通过局部变量才能访问对象,将对象封闭在线程中。这样它们位于执行线程的栈中,其他线程无法访问这个栈。例如下面代码中的numPairs和TreeSet对象,始终封闭在线程内。

    public int loadTheAck(Collection<Animal> candidates) {
      SortedSet<Animal> animals;
      int numPairs = 0;
      Animal candidate = null;
      
      animals = new TreeSet<Animal>(new SpeciesGenderComparator());
      animals.addAll(candidates);
      for (Animal a : animals) {
        if (candidate == null || !candidates.isPotentialMate(a)) {
          candidate = a;
        } else {
          ark.load(new AnimalPair(candidate, a));
          ++numPairs;
          candidate = null;
        }
      }
      return numPairs;
    }
    

    在线程内部使用了非线程安全的类,那么该对象仍然是线程安全的。但需要注意的是,如果发布了对集合animals的引用,那么封闭性被破坏,就是非线程安全的了。

    • ThreadLocal类

    ThreadLocal类是维持线程安全性的一种更规范的做法。ThreadLocal提供了get和set等访问接口或方法,为每一个使用该变量的线程提供了一个独立的副本,get总是返回由当前线程调用set时设置的值。

    ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行分享。例如从JDBC的连接对象中获取Connection时,默认是非线程安全地,可以采用以下的实现方式:

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

    可以将ThreadLocal<T>视为包含了Map<Thread, T>对象,保存了特定线程的值。在将一个单线程应用移植到多线程环境中时,可以将共享的全局变量转换为ThreadLocal对象,维持线程安全性。

    4 不变性

    如果一个变量在创建后不能被修改,那么这个对象就成为不可变对象,这样就不会出现多线程引起的各种问题。

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

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

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

    这是一个不可变对象的实例

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

    final类型的域是不能修改的,尽管final域所引用的对象是可变的。final域能确保初始化过程的安全性,可以不受限制地访问不可变对象,并在共享时无需同步。即使对象是可变的,将对象的某些域声明为final类型,仍然可以简化对状态的判断,限制对象的可变性相当于限制了该对象可能的状态集合,也是有意义的。

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

    [扩展阅读]:使用Volatile类型来发布不可变对象

    5 安全发布

    最后我们再来谈一谈如何安全地在多个线程间共享对象。在没有足够的同步情况下发布对象,可能会出现一些异常情况,比如以下的代码:

    public Holder holder;
    
    public void initialize() {
      holder = new Holder(42);
    }
    
    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对象对其他线程可见时,一个尚未完全创建的对象并不拥有完整性,其他观察该对象的线程将看到对象处于不一致的状态。

    如何正确发布对象,这个是和对象的类型相关的。

    • 不可变对象不需要额外的同步就可以安全发布

    再回顾一下不可变对象的三个条件:状态不可修改,所有域都是final类型,以及正确的构造过程。Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证,这样任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即时在发布这些对象时没有使用同步。

    • 事实不可变对象也可以在没有额外同步的情况下安全发布

    如果一个对象的状态在发布后不会再改变,那么把这种对象成为“事实不可变对象”,这些对象在发布后,只需将它们视为不可变对象即可。

    例如,Date对象本身是可变的,但如果Data对象的值在放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值安全发布,并且在访问时不需要额外的同步。

    • 可变对象需要以同步的方式才能安全发布

    一个正确构造的可变对象,可以通过以下方式安全地发布:

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

    在线程安全库中的容器类提供了安全发布的保证,将一个键或值放入Hashtable、synchronizedMap或者ConcurrentMap中,将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以实现安全发布。

    同样,要发布一个静态构造的对象,最简单和安全的方式是使用静态的初始化器:

    public static Holder holder = new Holder(42);
    

    对可变对象的安全发布,只能确保“发布当时”状态的可见性,在发布后每次访问同样需要使用同步来确保后续修改操作的可见性。

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

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

    相关文章

      网友评论

          本文标题:《Java并发编程实战》 - 基础知识②

          本文链接:https://www.haomeiwen.com/subject/rpdovftx.html