写点什么

高并发下如何实现对象的共享?

作者:JavaEdge
  • 2022 年 1 月 11 日
  • 本文字数:5188 字

    阅读完需:约 17 分钟

高并发下如何实现对象的共享?

本文介绍如何共享和发布对象,使它们能够安全地由多个线程同时访问。两篇博文合起来就形成了构建线程安全类以及通过 juc 类库构建并发应用程序的重要基础。

1 可见性

通常,我们无法保证执行读操作的线程能看到其他线程写入的值,因为每个线程都由自己的缓存机制。为确保多个线程之间对内存写入操作的可见性,必须使用同步机制。


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; }}
复制代码


看起来会输出 42,但事实上很可能根本无法终止,因为读线程可能永远看不到 ready 的值;更奇怪的是可能输出 0,因为读线程看到了写入 ready 的值,却没有看到之后写入 number 的值,这种现象称为“重排序”(Reordering)。


在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。


有种简单方法避免这些复杂的问题:只要有数据在多个线程之间共享,就该使用正确的同步。

1.1 失效数据

除非在每次访问变量时使用同步,否则很可能获得变量的一个失效值。失效值可能不会同时出现:一个线程可能获得一个变量的最新值,而获得另一个变量的失效值。失效数据还可能导致一些令人困惑的故障,如:意料之外的异常、被破坏的数据结构、不精确的计算、无限循环等。


//非线程安全的可变整数类@NotThreadSafepublic class MutableInteger {    private int value;
public int get() { return value; }
public void set(int value) { this.value = value; }}
复制代码


此类非线程安全,因为 get 和 set 方法都是在没有同步的情况下访问 value 的失效值很容易出现:若某线程调用 set,则另一个正在调用 get 的线程可能看到更新后的 value 值,也可能看不到。


//线程安全的可变整数类@ThreadSafepublic class SynchronizedInteger {    @GuardedBy("this") private int value;
public synchronized int get() { return value; }
public synchronized void set(int value) { this.value = value; }}
复制代码


通过对 set,get 进行同步,可使此类成为一个线程安全的类。仅对 set 同步不够,调用 get 的线程仍可能看见失效值。

1.2 非原子的 64 位操作

对于非 volatile 类型的 long 和 double 变量,JVM 允许将 64 位的读操作或写操作分解为两个 32 位的操作。所以,当读取该类变量的操作在不同的线程时,很可能会读取到某个值的高 32 位和另一个值的低 32 位,造成读取到是一个随机值。除非用关键字 volatile 来声明它们,或者用锁保护起来。

1.3 加锁和可见性

当某线程执行由锁保护的同步代码块时,可以看到其他线程之前在同一同步代码块中的所有操作结果。如果没有同步,将无法实现上述保证。


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

1.4 volatile 变量

用于确保将变量的更新操作通知到其他线程,访问 volatile 变量时不会执行加锁操作,也就不会使执行线程阻塞,是一种比 sychronized 更轻量级的同步机制.


编译器与运行时都会注意到此变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序.


volatile 变量不会被缓存在寄存器或其他处理器不可见的地方,因此在读取 volatile 变量时总会返回最新写入的值.


从内存可见性来看:写入 volatile 变量相当于退出同步代码块,读取则相当于进入同步代码块(并不建议过度依赖此特性,通常比使用锁的代码还复杂)


仅当能简化代码的实现及对同步策略的验证时,才该用.若在验证正确性时需要复杂判断可见性,就不要使用!正确使用方式包括:


  • 确保它们自身状态的可见性

  • 确保它们所引用对象的状态的可见性

  • 标识一些重要的程序周期事件的发生(如初始化或关闭)


// 数绵羊volatile boolean asleep;...while(!asleep){   countSomeSheep();}
复制代码


  • 代码分析一种典型用法:检查某个状态标记判断是否退出循环.示例中,线程试图通过数绵羊方法进入休眠状态.为了使此示例能正确执行,asleep 必须为 volatile 型.否则,当 asleep 被另一个线程修改时,执行判断的线程却发现不了.亦可使用加锁保证,但代码会很复杂.


虽然方便,但也存在局限性.常用做某个操作完成,发生中断或状态的标志,如上例的 asleep 标志..但语义不足以确保递增操作的原子性,除非确保只有一个线程对变量执行写操作(后文的原子变量常做一种"更好的 volatile 变量").加锁机制既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性


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


  • 对变量的写入操作不依赖变量的当前值,或能确保只有单个线程更新变量的值

  • 该变量不会与其他状态变量一起纳入不变性条件中

  • 在访问变量时不需要加锁

2 发布与逸出

发布:使对象能够在当前作用域之外的代码中使用.发布方式:


  • 将一个指向该对象的引用保存到其他代码可以访问的地方(最简单的就是保存到公有的静态变量)

  • 非私有方法中返回该引用

  • 将引用传递到其他类的方法中


当某个不应该发布的对象被发布时,就被称为逸出.


//使内部的可变状态逸出(不要这样做!!!)class UnsafeStates {    private String[] states = new String[]{            "AK", "AL" /*...*/    };
public String[] getStates() { return states; }}
复制代码


  • 代码分析如此发布 states 有问题,因为任何调用者都能修改这个数组的内容.states 已经逸出了它所在的作用域,因为这个本应是 private 的变量已经被发布了.


//this引用隐式地在构造函数中逸出public class ThisEscape {    public ThisEscape(EventSource source) {        source.registerListener(new EventListener() {            public void onEvent(Event e) {                doSomething(e);            }        });    }}
复制代码


  • 代码分析当 ThisEscape 发布 EventListener 时,也隐含发布了 ThisEscape 实例本身,因为内部类的实例包含了对外部类实例的隐含引用.


构造过程中,另一个常见错误是,在构造器启动一个线程.此时,无论是显式创建(传给构造器)或隐式(内部类),this 引用都会被创建的线程共享.在对象尚未完全构造之前,新的线程就可以看见它.在构造器创建线程并无错误,但最好不要立即启动,而是通过 start 或 initialize 方法启动.在构造器调用一个可改写的实例方法时,也会导致 this 引用逸出.


想在构造器注册一个监听器或启动线程,可使用一个私有的构造器和一个公共的工厂方法.如下示例:


public class SafeListener {    private final EventListener listener;
private SafeListener() { listener = e -> doSomething(e); }
public static SafeListener newInstance(EventSource source) { SafeListener safe = new SafeListener(); source.registerListener(safe.listener); return safe; }}
复制代码

3 线程封闭

一种避免使用同步的方式就是不共享数据.


如果仅在单线程内访问数据,就不需要同步,这就被称为线程封闭.线程封闭是程序设计中的考虑因素,必须在程序中实现.Java 也提供了一些机制帮助维护线程封闭性,比如局部变量和 ThreadLocal 类.

3.1 Ad-hoc 线程封闭

维护线程封闭性的职责完全由程序实现来承担.


使用 volatile 变量是实现 Ad-hoc 线程封闭的一种方式,只要能保证只有单个线程对共享的 volatile 变量执行写操作,就可以安全地在这些变量上进行“读-改-写”操作,volatile 变量的可见性又保证了其他线程能够看到最新的值。


Ad-hoc 线程封闭是非常脆弱的,没有语言特性可使对象直接封闭到目标线程.因此在程序中尽量少使用.在可能的情况下,使用其他更强的线程封闭技术.##3.2 栈封闭在栈封闭中,只能通过局部变量才能访问对象.局部变量的固有属性之一就是封闭在执行线程中它们位于执行线程的栈中,其他线程无法访问此栈.即使使用了非线程安全的对象,该对象仍然是线程安全的.

3.3 ThreadLocal 类

使用 ThreadLocal 是一种更规范的线程封闭方式,它能使线程中的某个值与保存值的对象关联起来。提供了 get 与 set 等访问接口方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值.


常用于防止对可变的单实例变量或全局变量进行共享.


如下示例,通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接:


//使用TheadLocal来维持线程封闭性public class ConnectionDispenser {    static String DB_URL = "jdbc:mysql://localhost/mydatabase";
private ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() { public Connection initialValue() { try { return DriverManager.getConnection(DB_URL); } catch (SQLException e) { throw new RuntimeException("Unable to acquire Connection, e"); } }; };
public Connection getConnection() { return connectionHolder.get(); }}
复制代码


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


当某个线程初次调用 get 方法时,就会调用 initialValue 来获取初始值.可将 ThreadLocal < T >看作包含了 Map< Thread,T>对象,保存了特定于该线程的值,但 ThreadLocal 的实现并非如此.这些特定于线程的值存在 Thread 对象中,当线程终止后,这些值会作为垃圾被回收.


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

4 不变性

不可变对象:


满足以下条件:

  • 对象创建以后其状态就不能修改

  • 对象的所有域都是 final 类型(final 类型域是不能被修改的)

  • 对象是正确创建的(在对象的创建期间,this 引用没有逸出)


在被创建后其状态就不能被修改,且必线程安全.


在 JMM 中,final 域能确保初始化过程的安全性,从而可以无限制地访问不可变对象,并在共享这些对象时无须同步.

5 安全发布

任何线程都可在无额外同步情况下安全访问不可变对象,即使在发布时没有使用同步.


然而,若 final 域所指向为可变对象,访问这些可变对象的状态时仍需同步.

安全发布常用模式

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


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


  • 在静态初始化函数里初始化一个对象引用

  • 将对象的引用保存到 volatile 类型的域或者 AtomicReference 对象中

  • 将对象的引用保存到某个正确构造对象的 final 类型域中

  • 将对象的引用保存到一个由锁保护的域中


线程安全库中的容器类提供了以下的安全发布保证:


  • 通过将一个键或者值放入 Hashtable、synchronizedMap 或者 ConcurrentMap 中,可以安全地将它发布给任何从这些容器中访问它的线程

  • 通过将某个对象放入 Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或者 synchronizedSet 中,可以将该对象安全地发布到任何从这些容器中访问该对象的线程通过将某个对象放入 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以将该对象安全地发布到任何从这些队列中访问该对象的线程


通常发布一个静态构造的对象,最简单安全的方式就是使用静态的初始化器:


public static Holder holder = new Holder(42);
复制代码


由 JVM 在类的初始化阶段执行,且由于 JVM 内部存在着同步机制,因此这样初始化的任何对象都能被安全发布.


事实不可变对象:对象从技术上来看是可变的,但其状态在发布后不会再改变.在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象.

对于可变对象,不仅在发布对象时需要同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性.


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


  • 不可变对象可以通过任意机制来发布。

  • 事实不可变对象必须通过安全方式来发布。

  • 可变对象必须通过安全方式来发布,而且必须是线程安全的或者用某个锁保护起来。

安全的共享对象

实用策略:


  • 线程封闭 线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改

  • 只读共享 在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它.共享的只读对象包括不可变对象和事实不可变对象

  • 线程安全共享 线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公共接口来进行访问而不需要进一步的同步

  • 保护对象 被保护的对象只能通过持有特定的锁来访问.保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象

发布于: 刚刚阅读数: 2
用户头像

JavaEdge

关注

正在征服世界的 Javaer。 2019.09.25 加入

曾就职于百度、携程、华为等大厂,阿里云开发者社区专家博主、腾讯云+社区2019、2020年度最佳作者、慕课网认证作者、CSDN博客专家,简书优秀创作者兼《程序员》专题管理员,牛客网著有《Java源码面试解析指南》。

评论

发布
暂无评论
高并发下如何实现对象的共享?