二、对象的共享

    xiaoxiao2025-08-10  13

    我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误 解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。同步还有另一个重要的方面:内存可见性(Memory Visibility),我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。

    一、可见性

    为了确保多个线程之间对内存写入操作的可见 性,必须使用同步机制。

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

    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; } } }

    NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪 的现象是,No Visibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到 之后写入number的值,这种现象被称为“重排序(Reordering)”。只要在某个线程中无法检测 到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线 程中的操作将按照程序中指定的顺序来执行。e当主线程首先写入number,然后在没有同步的 情况下写人ready,那么读线程看到的顺序可能与写人的顺序完全相反。

    在没有同步的情况下,编译器、处理器以及运行对‘等都可能对操作岛执行顺序进行 一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的热兮;^序进 行判断,几乎无法得出正确的结论。

    只要有数据在多个线程之间共享,就使用正确的同步

    失效的数据

    NoVisibility展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线 程査看ready变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否 则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获 得某个变量的最新值,而获得另一个变量的失效值。

    失效数据可能导致一些令人困惑的故障,例如意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环等。

    下列Mutablelnteger不是线程安全的,因为get和set都是在没有同步的情况 下访问value的。与其他问题相比,失效值问题更容易出现:如果某个线程调用了 set,那么另 一个正在调用get的线程可能会看到更新后的value值,也可能看不到。

    public class Mutablelnteger { private int value; public int get() { return value; } public void set(int value) { this.value = value; } }

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

    public class Mutablelnteger { private int value; public synchronized int get() { return value; } public synchronized void set(int value) { this.value = value; } } 非原子的64位操作

    当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性( out-of-thin-air safety)。 最低安全性适用于绝太多数变量,但是存在一个例外:非volatile类型的64位数值变量 (double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的髙32位和另一个值的低32位 因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的 变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

    加锁与可见性

    内置锁可以用于确保某个线程以一种可预测的方式来査看另一个线程的执行结果,如图所示。当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码 块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在3获得锁后同样可以由B看 到。换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代 码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。

    Volatile变量

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

    在 访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一 种比sychronized关键字更轻量级的同步机制。

    volatile变量对可见性的影响比volatile变量本身更为重要。当线程A首先写入一个 volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的 值,在B读取了 volatile变量后,对B也是可见的。因此,从内存可见性的角度来看,写入 volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的 可见性,通常比使用锁的代码更脆弱,也更难以理解。

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

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

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

    二、发布与逸出

    “发布(Publish)” 一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象 被发布时,这种情况就被称为逸出(Escape)

    示列: 发布knownSecrets public static Set<Secret> knownSecrets; public void initialize() { knownSecrets = new HashSet<Secret>()}

    当发布某个对象时,可能会间接地发布其他对象。如果将一个Secret对象添加到集合 knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。程序清单中的UnsafeStates发布了本应为私有的状态数组。

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

    如果按照上述方式来发布states,就会出现问题,因为任何调用者都能修改这个数组的内 容。在这个示例中,数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。

    当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象 也都会被发布。

    假定有一个类C,对于C来说,“外部(Alien)方法”是指行为并不完全由C来规定的方法,包括其他类中定义的方法以及类C中可以被改写的方法(既不是私有[private]方法也不是 终结[final]方法)。当把一个对象传递给某个外部方法时,就相当于发布了这个对象。你无法知道哪些代码会执行,也不知道在外部方法中究竟会发布这个对象,还是会保留对象的引用并在随后由另一个线程使用。

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

    其中一种发布对象或其内部状态的机制就是发布一个内部的类实例,如程序清单的 ThisEscape所示。当ThisEscape发布EvenfListener时,也隐含地发布了ThisEscape实例本身, 因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用。

    // 隐士地使tgis引用逸出,不要这么做 public class ThisEscape { public ThisEscape(EventSource source) { source.registerListener( new EventListener() { public void onEvent(Event e) { doSomething(e); } }); } } 安全的对象构造过程

    如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造。因此,不要在构造过程中使this引用逸出。

    三、线程封闭

    当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

    Ad-hoc线程封闭

    Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭 是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到 目标线程上。事实上,对线程封闭对象(例如,GUI应用程序中的可视化组件或数据模型等) 的引用通常保存在公有变量中。

    当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系 统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。©

    在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile 变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写 入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且 volatile变量的可见性保证还确保了其他线程能看到最新的值。

    由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该 使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。

    栈封闭

    栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象(一个对象只能在当前方法访问,并且不能被逸出)。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变 量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆)比Ad-hoc线程封闭更易于维护,也更加健壮。

    ThreadLocal 类

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

    ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。 例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连 接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线 程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连 接,如程序清单中的ConnectionHolder所示。

    private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() { public Connection initialValue() { try { return DriverManager.getConnection("DB_URL"); } catch (SQLException e) { e.printStackTrace(); return null; } } }; public static Connection getConnection () { return connectionHolder.get(); }

    四、不变性

    满足同步需求的另一种方法是使用不可变对象(Immutable Object) 。 如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安 全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态 不改变,那么这些不变性条件就能得以维持。 不可变对象一定是线程安全的。 不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。 虽然在Java语言规范和Java内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。

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

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

    对象的所有域都是final类型

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

    Final域

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

    即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态 的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为final类型,也相 当于告诉维护人员这些域是不会变化的。

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

    五、安全发布

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

    // 程序清单3-14 public Holder holde; public void initialize() { holder = new Holder(42); }

    你可能会奇怪,这个看似没有问题的示例何以会运行失败。由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确地构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。

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

    你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象 处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。事实上,如果以下程序清单中的Holder使用程序清单3-14中的不安全发布方式,那么另一个线程在调用assertSanity时将抛出AssertionError。

    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域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况 是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的©。情况变得更加不可 预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值, 这也是 assertSainty 抛出 AssertionError 的原因。

    如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。

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

    由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一 种特殊的初始化安全性保证。我们已经知道> 即使某个对象的引用对其他线程是可见的,也并 不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致 的视图,就必须使用同步。

    另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对 象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有 域都是final类型,以及正确的构造过程。

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

    这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也 可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问 这些域所指向的对象的状态时仍然需要同步。

    安全发布的常用模式

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

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

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

    在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或synchronizedList 时,将满足上述最后一条需求。如果线程A将对象X放入一个线程安全的容器,随后线程B 读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读/写X的应用程序代码 中没有包含显式的同步。尽管Javadoc枉这个主题上没有给出很清晰的说明,但线程安全库中 的容器类提供了以下的安全发布保证:

    通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。

    通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或synchrodzedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。

    通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

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

    public static Holder holder = new Holder42);

    静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通 过这种方式初始化的任何对象都可以被安全地发布。

    事实不可变对象

    如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对 象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问 该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,并且如果对象状态不会 再改变,那么就足以确保任何访问都是安全的。

    如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为 “事实不可变对象(Effectively Immutable Object)”。在这些对象发布后,程序只需将它们视为不可变对象即可。通过使用事实不 可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。

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

    例如,Date本身是可变的©,但如果将它作为不可变对象来使用,那么在多个线程之间共享Date对象时;就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:

    public Map<String, Date> iastLogin = Collections.synchronizedMap(new HashMap<String, Date>());

    如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足 以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。

    可变对象

    如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可 变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后 续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线 程安全的或者由某个锁保护起来。

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

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

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

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

    安全地共享对象

    获得对象的一个引用时,你需要知道在这个引用上吋以执行哪些操作。在使用它之前是 否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问 方式。

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

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

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

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

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

    最新回复(0)