同步锁同步代码块同步_同步不是敌人

对于大多数编程语言,语言规范对线程和并发这一主题没有任何评论。 这些主题历来由平台或操作系统指定。 相反,Java语言规范(JLS)明确包含线程模型,并为开发人员提供了几种语言元素,可用于使他们的程序呈现线程安全性。

明确支持线程既是福也是祸。 虽然这使我们更容易编写利用线程功能和便利的程序,但这也意味着我们必须注意所编写类的线程安全性,因为任何给定的类都更有可能在多线程环境中使用。

许多用户首先发现自己必须了解线程不是因为他们正在编写创建和管理线程的程序,而是因为他们正在使用本身就是多线程的工具或框架。 任何使用Swing GUI框架或编写servlet或JSP页面的开发人员都(无论是否知道)都暴露于线程的复杂性。

Java架构师希望创建一种在现代硬件(包括多处理器系统)上可以很好运行的语言。 为了实现这个目标,管理线程之间的协调的工作大部分都退给了开发人员。 程序员必须指定线程之间共享数据的位置。 管理Java程序中线程之间的协调的主要工具是synchronized关键字。 在没有同步的情况下,JVM可以自由选择在不同线程中执行操作的时间和顺序。 在大多数情况下,这是可取的,因为它可以带来更高的性能,但是这给程序员增加了负担,使他们不得不确定这种优化何时会损害程序的正确性。

同步到底是什么意思?

大多数Java程序员完全从执行互斥(互斥信号量)或定义关键部分(必须原子运行的代码块)的角度来考虑同步块或方法。 尽管synchronized的语义确实包括互斥和原子性,但在监视器进入之前和监视器退出之后发生的事情的现实情况要复杂得多。

synchronized的语义确实保证了只有一个线程可以同时访问受保护的部分,但是它们还包括有关同步线程与主内存的交互的规则。 考虑Java内存模型(JMM)的一个好方法是假设每个线程都在单独的处理器上运行,并且尽管所有处理器都访问一个公共的主内存空间,但是每个处理器都有自己的缓存,可能并不总是与之同步。主内存。 在没有同步的情况下,允许(根据JMM)两个线程在同一内存位置看到不同的值。 在监视器(锁)上进行同步时,JMM要求在获取锁之后立即使该缓存无效,并在释放之前刷新该高速缓存(将所有修改的内存位置写回主内存)。 不难看出为什么同步会对程序性能产生重大影响。 经常刷新缓存可能会很昂贵。

走一条细线

无法正确同步的后果非常严重:数据损坏和争用情况,可能导致程序崩溃,产生不正确的结果或行为异常。 更糟糕的是,这些情况极少会偶尔出现(使问题很难被发现和重现。)如果测试环境与生产环境在配置或负载上大不相同,则这些问题可能根本不会发生。测试环境,导致错误的结论是我们的程序是正确的,而事实上它们只是还没有失败。

另一方面,不适当或过度使用同步会导致其他问题,例如性能下降和死锁。 虽然性能较差的问题肯定不如数据损坏严重,但仍然可能是一个严重的问题。 编写优秀的多线程程序需要走一条细线,进行足够的同步以保护您的数据免遭损坏,但又不至于冒死锁或不必要地损害程序性能的风险。

同步有多昂贵?

由于涉及到缓存刷新和失效的规则,因此Java语言中的同步块通常比许多平台提供的关键部分功能更为昂贵,这些平台通常使用原子的“测试并设置位”机器指令来实现。 即使程序仅包含在单个处理器上运行的单个线程,同步方法调用仍然比非同步方法调用慢。 如果同步实际上需要竞争锁,则性能损失会更大,因为将需要多个线程切换和系统调用。

幸运的是,对JVM的持续改进既提高了Java程序的整体性能,又降低了与每个发行版同步的相对成本,并且可以预期将来会进行改进。 此外,同步的性能成本通常被夸大了。 一位著名的消息人士提到,同步方法调用比不同步方法调用慢50倍之多。 尽管此声明可能是正确的,但它也极具误导性,并导致许多开发人员即使在需要时也避免进行同步。

严格按照百分比来表示同步的性能损失是没有意义的,因为无竞争的同步会对块或方法施加固定的性能损失。 此固定延迟所隐含的性能损失百分比取决于同步块中正在执行的工作量。 同步调用一个空方法可能比不同步调用一个空方法慢20倍,但是我们多久调用一次空方法呢? 当我们针对更具代表性的小型方法测量同步代价时,百分比数字很快就会下降到可以容忍的程度。

表1列出了其中一些数字。 它在几种不同的情况下以及在几种不同的平台和JVM上,将同步方法调用的成本与等效的非同步方法调用进行了比较。 在每种情况下,我都运行一个简单的程序,该程序测量循环的运行时间,该循环调用方法10,000,000次,同时调用同步版本和非同步版本,并比较结果。 表中的数据是同步版本与非同步版本的运行时间之比; 它显示了同步的性能损失。 在每次运行中,它都会调用清单1中所示的简单方法之一。

表1仅显示了同步方法调用与未同步方法调用的相对性能。 为了从绝对角度衡量性能损失,您还必须考虑JVM的速度改进,此数据未显示。 在大多数测试中,每个JVM版本的整体JVM性能都得到了显着改善,并且1.4 Java虚拟机发布后,它的性能很有可能会进一步提高。

表1.无竞争同步的性能损失
JDK staticEmpty 空的 hashmapGet 单身人士 创造
Linux / JDK 1.1 9.2 2.4 2.5 不适用 2.0 1.42
Linux / IBM Java SDK 1.1 33.9 18.4 14.1 不适用 6.9 1.2
Linux / JDK 1.2 2.5 2.2 2.2 1.64 2.2 1.4
Linux / JDK 1.3(无JIT) 2.52 2.58 2.02 1.44 1.4 1.1
Linux / JDK 1.3-服务器 28.9 21.0 39.0 1.87 9.0 2.3
Linux / JDK 1.3-客户端 21.2 4.2 4.3 1.7 5.2 2.1
Linux / IBM Java SDK 1.3 8.2 33.4 33.4 1.7 20.7 35.3
Linux / gcj 3.0 2.1 3.6 3.3 1.2 2.4 2.1
Solaris / JDK 1.1 38.6 20.1 12.8 不适用 11.8 2.1
Solaris / JDK 1.2 39.2 8.6 5.0 1.4 3.1 3.1
Solaris / JDK 1.3(无JIT) 2.0 1.8 1.8 1.0 1.2 1.1
Solaris / JDK 1.3-客户端 19.8 1.5 1.1 1.3 2.1 1.7
Solaris / JDK 1.3-服务器 1.8 2.3 53.0 1.3 4.2 3.2
清单1.示例方法
public static void staticEmpty() {  }

  public void empty() {  }

  public Object fetch() { return field; }

  public Object singleton() {

    if (singletonField == null)
      singletonField = new Object();
    return singletonField;
  }

  public Object hashmapGet() {
    return hashMap.get("this");
  }

  public Object create() { 
    return new Object();
  }

这些小的基准测试还说明了在存在动态编译器的情况下解释性能结果所面临的挑战。 有和没有JIT的1.3 JDK在数量上的巨大差异需要一些解释。 对于非常简单的方法( emptyfetch ),基准测试的性质(除了执行几乎不起作用的紧密循环外,什么也不做)使JIT动态编译整个循环,从而将运行时间几乎压缩为零。 JIT是否能够在现实世界的程序中这样做取决于许多因素,因此非JIT时序号可能对于进行公平的比较更为有用。 无论如何,对于更重要的方法( createhashmapGet ),JIT无法像使用简单方法那样对不同步的情况进行巨大的改进。 同样,也没有说JVM是否能够优化测试的重要部分。 同样,可比较的IBM和Sun JDK之间的差异反映了这样一个事实,即IBM Java SDK更加积极地优化了非同步循环,而不是同步版本更昂贵。 这在原始计时编号中很明显(此处未显示)。

我们可以从这些数字得出的结论是,尽管无竞争的同步仍然会降低性能,但对于许多非平凡的方法,它会降至“合理”的水平。 在大多数情况下,罚金大约在10%到200%(相对较小的数字)之间。 结果,尽管仍然不建议同步每个方法(这也增加了死锁的可能性),但我们不必如此担心同步。 这里使用的简单测试表明,无竞争的同步比对象创建或HashMap查找的成本要便宜。

当早期的书籍和文章认为无竞争的同步会付出巨大的代价时,许多程序员竭尽全力避免不惜一切代价进行同步。 这种担心导致了许多有问题的技术,例如双重检查锁定习惯用法(DCL)。 在许多有关Java编程的书籍和文章中,广泛推荐使用DCL,它似乎是避免不必要的同步的一种非常聪明的方法,但实际上它不起作用,应该避免。 它不起作用的原因非常复杂,超出了本文的范围(请参阅参考资料中的后续链接)。

诺洛竞争

假设正确使用了同步,则当线程实际争用锁时,会感受到同步的实际性能影响。 无竞争的同步和有竞争的同步之间的成本差异很大; 一个简单的测试程序表明,竞争性的同步要比非竞争性的同步慢50倍。 这个事实与上面的观察结果相结合,表明竞争的同步在成本上可与至少50个对象创建相媲美。

然后,在调整应用程序对同步的使用时,我们应该努力减少实际争用的数量,而不是简单地尝试完全避免使用同步。 本系列的第2部分将重点介绍减少争用的技术,包括减少锁粒度,减小同步块的大小以及减少跨线程共享的数据量。

我什么时候需要同步?

为了使程序具有线程安全性,必须首先确定将在线程之间共享哪些数据。 如果您正在编写稍后可能被另一个线程读取的数据,或者正在读取可能已经被另一个线程写入的数据,则该数据是共享的,并且在访问它时必须进行同步。 一些程序员惊讶地发现这些规则也适用于您只是检查共享引用是否为非空的情况。

许多人发现这些定义出奇地严格。 通常认为,您不需要获取锁即可简单地读取对象的字段,尤其是因为JLS保证32位读取将是原子的。 不幸的是,这种直觉是不正确的。 除非所讨论的字段声明为volatile ,否则JMM不需要底层平台提供跨处理器的缓存一致性或顺序一致性,因此在某些平台上,有可能在没有同步的情况下读取陈旧的数据。 请参阅相关主题的更多细节。

确定要共享的数据后,还必须确定如何保护这些数据。 在简单的情况下,您可以通过简单地声明它们为volatile来保护它们。 在其他情况下,您必须在读取或写入共享数据之前获取一个锁,这是一个好习惯,明确标识用于保护给定字段或对象的锁,并用您的代码记录该锁。

还值得注意的是,仅同步访问器方法(或将基础字段声明为volatile )可能不足以保护共享字段。 考虑以下示例:

...
  private int foo;
  public synchronized int getFoo() { return foo; } 
  public synchronized void setFoo(int f) { foo = f; }

如果调用者想增加foo属性,则以下代码不是线程安全的:

...
  setFoo(getFoo() + 1);

如果两个线程试图同时增加foo ,则结果可能是foo的值增加1或2,具体取决于时间。 呼叫者将需要同步锁定以防止出现这种竞争情况; 对您的类JavaDoc来说,最好指定要同步的锁,这样您的类的调用者就不必猜测。

上面的情况很好地说明了我们必须如何注意多个粒度级别的数据完整性; 同步访问器函数可确保调用者可以访问属性值的最新版本,但是如果我们希望属性的将来值与当前值一致,或者希望多个属性彼此一致,则还必须同步复合操作,可能在较粗粒度的锁上。

如有疑问,请考虑使用同步包装器

有时,在编写类时,我们不知道是否要在共享上下文中使用它。 我们希望我们的类是线程安全的,但是我们也不想给总是在单线程环境中使用的类增加负担,并且我们可能不知道同步的开销是多少。使用该类时。 幸运的是,我们通常可以通过提供一个同步包装器同时实现这两种方式。 Collections类是此技术的一个很好的例子。 它们是不同步的,但是对于框架中定义的每个接口,都有一个同步包装器(例如Collections.synchronizedMap() ),该包装器用同步版本包装每个方法。

结论

尽管JLS为我们提供了使我们的程序具有线程安全性的工具,但是线程安全性并不是免费的。 使用同步会降低性能,而无法正确使用同步则会使我们面临数据损坏,结果不一致或死锁的风险。 幸运的是,在过去的几年中,JVM有了实质性的改进,减少了与正确使用同步相关的性能损失。 通过仔细分析如何在线程之间共享数据,并适当地同步共享数据上的操作,可以使程序具有线程安全性,而不会产生过多的性能开销。


翻译自: https://www.ibm.com/developerworks/java/library/j-threads1/index.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值