使用不常进行修改的可变集合来减少应用程序的同步开销

本文介绍了一种在Java多线程环境下减少同步开销的技术,通过使用分代数据结构来提高性能。此方法适用于数据更新频率较低的情况,通过安全发布有效不可变对象避免了同步方法成为性能瓶颈。

使用多个 Java 线程之间共享数据的缺点在于数据访问必须同步,从而避免出现不一致的内容视图,后者可能会导致应用程序失败。例如,Hashtable 类的 put()get() 方法是同步的。因为需要实现同步,所以 put()get() 方法在执行时将同时单独地访问数据;否则,应用程序数据结构可能会被破坏。

当某个应用程序的线程频繁访问这些方法,导致线程出现阻塞时,这些方法的同步点将成为瓶颈。每次只能有一个线程获得内容的访问权。而其他线程必须等待。如果线程出现排队等候(如果不是这样,线程能够进行其他有用操作),性能和吞吐量将下降。当性能分析显示同步方法实际上会导致排队点时,对代码进行优化是有益的。

对于很少进行修改的数据,一种被称为分代数据结构(generational data structure)的技术允许您使用较低的 volatile 开销来安全地发布可变数据结构。当数据结构被频繁访问但很少进行修改时,这将获得性能增益。例如,可以使用未同步的数据结构如 HashMap,而不是同步的数据结构如 Hashtable。该技术的关键内容包括:

  1. 发生更新时,制作数据结构的新副本。
  2. 完全填充它。
  3. 使用 volatile 引用将更新安全地发布到所有客户。

使用该技术,getput 操作永远不会在数据结构的同一个示例上同时执行。将确保两个线程不会尝试同时更新数据结构,并且读取线程会始终查看一致的、最新版本的数据。(即使数据被频繁更新,仍可以使用该方法,不过通过改善并发性而获得的性能增益将损失。频繁地重新填充数据结构可能会抵消由消除同步存取器方法而获得的性能增益。)

成对类的适用性

Hashtable 是一种 Java 类,它提供了多线程共享数据的访问。HashMap 在功能上类似于 Hashtable,但它不是线程安全的。本文所提供的技术适用于其他成对类,它们彼此相似,不同之处在于其中一个类有同步的访问方法,而另一个没有。例如,Vector 有同步的访问方法,而 ArrayList 没有。这两个类都提供相似的功能,而且可以使用本文所讨论的方法。

该技术使用了 Java 语言的三个特性;

  • 自动垃圾收集。当对象的最后一个引用不再使用时,Java 运行时可以自动释放该对象。应用程序不需要进行其他操作,只需确认当应用程序不再使用某个对象时,没有任何引用指向该对象。早期创建的对象会在最后一个客户使用完成后被自动释放。

  • 对象引用的原子性。一个获取对象引用的简单赋值语句是不能被中断的。这意味着只要消费线程可以使用较旧的(但完整的)对象副本生成正确结果,就没有必要围绕单个对象的赋值语句实现同步。但是,必须注意的是仍需在生产者(producer)线程上采取操作,以确保在执行赋值之前创建完成新的对象。正如本文 讨论 部分所述,生产者线程中需要使用同步代码以确保在对象赋值前完成对象创建。但是,不必在消费者线程中加入同步代码,这将消除开销较大的排队点。

  • Java 内存模型。Java 内存模型规定了 synchronizedvolatile 的语义。这些规则定义了共享对象及其内容在何时对于除当前正在执行的线程之外的线程是可见的。

为维持两个独立的数据结构实例而对数据结构中的数据进行修改时,您可以使用 Java 语言的以上特性。一旦其中一个被填充,它就不会再次更改。它是有效不可变的。如果允许 getput 操作在同一个数据结构上同时执行,这是比较危险的。本文所讨论的技术将确保所有 put 操作会在执行任何 get 操作之前完成。

技术

清单 1 中的示例代码阐述了该技术:


清单 1. 避免出现排队点的生产者/消费者代码

static volatile MapcurrentMap = new HashMap(); // thismustbevolatiletoensure
// consumerswillseeupdatedvalues
static Objectlockbox = new Object();

public static void buildNewMap() ... {//Thisiscalledbytheproducer
//whenthedataneedstobeupdated.

synchronized(lockbox)...{//Thismustbesynchronizedbecause
//oftheJavamemorymodel.

MapnewMap
=newHashMap(currentMap);//forcaseswherenewdataisbasedon
//theexistingvalues,youcanusethe
//currentMapasastartingpoint.

//addorremoveanyneworchangeditemstothenewMap
newMap.put(....);
newMap.put(....);

currentMap
=newMap;

}

/**//*Aftertheabovesynchronizationblock,everythingthatisintheHashMapis
visibleoutsidethisthread.Theupdatedsetofvaluesisavailableto
theconsumerthreads.

Aslongasassignmentoperationcancompletewithoutbeinginterrupted
andisguaranteedtobewrittentosharedmemoryandtheconsumercan
livewiththeoutofdateinformationtemporarily,thisshouldworkfine.
*/



}

public static ObjectgetFromCurrentMap(Objectkey) ... {//Calledbyconsumerthreads.


Mapm
=currentMap;//Nolockingaroundthisisrequired.

Objectresult
=m.get(key);//getonaHashMapisnotsynchronized.

//Doanyadditionalprocessingneededusingtheresult.

return(result);

}

下面将详细讨论清单 1 的内容:

  • 第二个变量 — 即清单 1 中的 newMap — 将保存用数据填充的 HashMap。这个变量受 synchronized 块的保护,一次只能由一个线程使用 — producer 线程的工作是进行以下操作:

    • 创建新的 HashMap 并将其存储在 newMap 变量中。
    • newMap 上执行整个 put 操作集,这样消费者线程所需的所有数据都包含在 newMap 中。
    • newMap 被完全填充后,将 newMap 的值指定为 currentMap

    由于定时器(或侦听器)会在某些外部数据(如数据库)发生更改时被唤醒,因此可以定期执行生产者线程。

  • 需要使用 currentMap 内容的消费者线程仅仅访问对象并执行 get 操作。请注意 m = currentMap 赋值是一个单元操作,而且不需要进行同步,即使其他线程可能正在访问对象的值。这是安全的,因为 currentMap 是可变的,并且是在生产者的同步块内部进行填充。这意味着通过 currentMap 引用读取的数据结构内容至少会与 currentMap 引用本身保持一致的更新程度。

讨论

一旦将 newMap 指定为 currentMap,则内容始终不会更改。实际上,HashMap 是不可变的。这将允许多个 get 操作并行运行,从而获得主要性能改善。根据 Brian Goetz 在 Java Concurrency in Practice(参阅 参考资料)中 3.5.4 节的论述,即 “无需额外的同步即可使用安全发布的有效不可变对象”,安全发布是 volatile 引用的结果。

读取数据时,惟一可能发生更改的就是 currentMap 变量的对象引用。在消费者线程访问某个值的同时,生产者线程将使用新值覆盖当前值。因为对象引用是 Java 语言中的单元操作,所以在访问该对象时,消费者线程没有必要进行同步。最糟糕情形可能是消费者线程获得 currentMap 引用,然后生产者线程使用较新的内容覆盖该引用。在这种情况下,消费者线程会使用稍微有些旧但仍保持内部一致的数据。如果消费者线程在生产者线程准备运行的前一秒执行,则会出现同样结果。通常,这样不会引起任何问题。关键在于 currentMap 的内容会在发布时始终保持完全一致和不可变。

发生这种竞争时,消费者线程可能会使用 “旧” 版本数据的引用。“新” 的对象引用已经覆盖旧版本,但某些消费者线程仍使用旧版本。当最后一个消费者线程不再使用对旧对象的引用后,该对象将被释放并进行垃圾收集。Java 运行时将记录何时发生上述操作。应用程序不必显式释放旧对象,因为对象释放是自动进行的。

可以基于应用程序的需要,定期创建新版的 currentMap。按照上述步骤进行操作,可以确保这些更新能够安全地反复进行。

清单 1 中的 synchronized 块必需确保两个生产者线程不会同时竞争更新 currentMap。那样可能会导致数据损失,从而导致消费者线程查看不确定的结果。synchronized 将阻止优化程序作出这种决策,实际上是将整个映射创建作为原子操作处理。关键字 volatile 可以保证消费者线程在 currentMap 变量的值发生更改后不会继续查看其旧值。更重要的是,可以确保客户通过取消引用对象引用而获得的值至少与引用本身保持一致的更新程度。而普通的引用不能提供这种有序保证。

使用 synchronized 块和 volatile 关键字所带来的影响是消费者线程可以查看一致的视图。数据结构在发布后不会被修改这一事实将为生产者线程提供帮助。在这种情形中 — 发布有效不可变的对象图形 — 所需做的事情就是安全地发布根对象引用。请注意,也可以对根对象引用的消费者访问进行同步,但这将成为排队点,而排队点正是该技术试图避免的。Brian Goetz 将这种方法称为 “开销较低的读-写锁” 技巧(参阅 参考资料)。

结束语

本文所讨论的技术适用于共享数据很少更改且由多线程同时访问的场合。不过该技术仅适用于应用程序不要求 使用绝对最新数据的场合。

最终结果是并发访问随时间变化的共享数据。在要求高并发性的环境中。该技术可以避免在应用程序内部包含不必要的排队点。

需要注意的是由于 Java 内存模型的复杂性,本文所讨论的技术仅用于 Java 5.0 及更高版本。在早期的 Java 版本中,客户机应用程序面临的风险是查看未被完全填充的 HashMap,或 HashMap 的已破坏的、无效的或不一致的内部数据结构视图。

致谢

本文作者非常感谢 Brian Goetz 对本文作出的技术评论和建议,使本文更加完整、严谨和准确。

参考资料

学习

<think>嗯,用户想了解不可修改集合(Immutable Collection)的应用场景和优势。首先,我需要回忆一下之前学过的相关知识。记得不可变集合一旦创建就不能被修改,只能读取。那它们的应用场景应该和这种特性相关。 首先想到的是并发编程,因为多线程环境下,如果数据不可变,就不存在竞态条件,自然线程安全。引用里的资料也提到这点,比如引用[2]说在并发程序中用不可变集合保证线程安全。所以这可能是一个主要应用场景。 然后,防御性编程也是一个点。比如,当把集合传递给外部库或者不可信的代码时,如果集合是不可变的,就可以防止被意外修改,保护数据完整性。引用[1]和[2]都有提到这点,特别是引用[4]提到Immutable.js的作用,避免意外的状态更新。 还有函数式编程,不可变数据是函数式的重要原则。每次修改生成新对象,避免副作用,这样代码更可靠。引用[3]和[5]都提到Scala和Clojure中的例子,说明不可变集合在函数式编程中的优势。 另外,作为常量使用也是一个场景。比如配置信息、枚举值,这些不需要修改的数据用不可变集合更高效,因为可以安全共享,节省内存。引用[2]提到不可变集合更有效利用内存,可能因为它们可以被多个引用共享而无需复制。 性能方面,虽然每次修改会创建新对象,但不可变结构可能通过结构共享来优化,比如只复制部分数据,其余部分引用原来的。引用[4]提到Immutable.js不是深拷贝,而是结构共享,这样减少内存消耗。这可能在某些情况下比可变集合更高效,尤其是在频繁读取而少修改的场景。 还有缓存和快照功能,比如需要保存某个状态的历史版本,不可变集合可以方便地存储,因为一旦创建就不会变。引用[5]提到数据库事务中使用不可变数据,可能和快照隔离有关。 总结下来,应用场景包括并发编程、防御性编程、函数式编程、常量定义、缓存和快照,以及性能优化。优势则是线程安全、避免意外修改、简化代码、内存高效等。 需要检查是否有遗漏的场景或优势。例如,不可变集合是否在测试中有用?因为状态不可变,测试结果更可预测。引用里没有明确提到这点,但可能属于防御性编程的一部分。 另外,引用[4]强调不可变数据集合和深拷贝的区别,说明它们不是简单的复制,而是结构共享,这点在解释优势时需要提到,避免误解。 可能用户还关心何时使用可变集合,或者它们的缺点,比如频繁修改时的性能问题,但用户的问题主要聚焦在应用场景和优势,所以重点回答这些方面即可。 最后,确保回答结构清晰,分点列出场景和优势,并正确引用资料中的内容作为支持。</think>### 不可修改集合(Immutable Collection)的应用场景及优势 #### 一、应用场景 1. **并发编程线程安全** 不可变集合在创建后无法被修改,因此多线程环境下无需加锁即可实现线程安全。例如,在分布式系统或高并发服务中,使用可变集合可避免数据竞争和状态不一致问题[^2][^5]。 2. **防御性编程** 当需要将集合传递给外部库或不可信代码时,不可变集合可防止数据被意外修改。例如,在API接口返回敏感数据时,使用可变集合能确保数据完整性[^1][^4]。 3. **函数式编程范式** 函数式编程强调无副作用(Side-effect-free),不可变集合天然支持这一特性。每次修改操作都会生成新集合,而非直接修改原数据,例如Scala的`immutable.Map`和Clojure的持久化数据结构[^3]。 4. **常量配置管理** 不可变集合适合存储常量、配置参数或枚举值。例如,在Java使用`ImmutableSet.of("READ", "WRITE")`定义文件操作权限,确保配置在生命周期内不变[^2]。 5. **缓存历史快照** 在需要记录数据历史状态的场景(如事务回滚、版本控制),不可变集合可作为轻量级快照存储,避免深拷贝带来的性能损耗[^4]。 --- #### 二、核心优势 1. **线程安全** 天然支持无锁并发,减少同步机制的开销[^5]。 2. **数据可靠性** 通过禁止修改操作,避免因意外改动导致的逻辑错误,提升代码健壮性。 3. **内存效率** 不可变集合通过结构共享(如Trie树)优化内存使用,多个版本可能共享底层数据,而非全量复制。例如,Immutable.js通过持久化数据结构减少内存占用[^3][^4]。 4. **函数式编程友好** 支持纯函数设计,便于实现状态可预测的代码逻辑。 5. **简化调试测试** 数据不可变性使得程序状态更易追踪,降低了调试复杂度[^4]。 --- #### 三、示例(Java中的不可变集合) ```java // 使用Guava创建不可变集合 ImmutableList<String> colors = ImmutableList.of("Red", "Green", "Blue"); // 尝试修改会抛出UnsupportedOperationException colors.add("Yellow"); // ❌ 运行时异常 ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值