`

【摘抄】同步和争用以及减少争用的手段

 
阅读更多

synchronized真正意味着什么?

synchronized 的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可 能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大,频繁地刷新缓存代 价会很大。

 

同步的代价有多大?

对非争用同步而言,虽然存在性能损失,但在运行许多不是特别微小的方法时,损失可以降到一个合理的水平;大多数情况下损失大概在 10% 到 200% 之间(这是一个相对较小的数目)。所以,虽然同步每个方法是不明智的(这也会增加死锁的可能性),但我们也不需要这么害怕同步。
由 于早期的书籍和文章暗示了无争用同步要付出巨大的性能代价,许多程序员就竭尽全力避免同步。这种恐惧导致了许多有问题的技术出现,比如说double- checked locking(DCL)。许多关于Java编程的书和文章都推荐DCL,它看上去真是避免不必要的同步的一种聪明的方法,但实际上它根本没有用,应该避 免使用它。

 

尽量不要争用

假设同步使用正确,若线程真正参与争用加锁,您也能感受到同步对实际性能的影响。并且无争用同步和争用同步间的性能损失差别很大;一个简单的测试程 序指出争用同步比无争用同步慢 50 倍。把这一事实和我们上面抽取的观察数据结合在一起,可以看出使用一个争用同步的代价至少相当于创建50个对象。
所以,在调试应用程序中同步的使用时,我们应该努力减少实际争用的数目,而根本不是简单地试图避免使用同步。这个系列的第2部分将把重点放在减少争用的技术上,包括减小锁的粒度、减小同步块的大小以及减小线程间共享数据的数量。

争用为什么慢?

争用同步之所以慢,是因为它涉及多个线程切换和系统调用。当多个线程争用同一个管程 时,JVM将不得不维护一个等待该管程的线程队列(并且这个队列在多个处理器间必须是同步的),这就意味着花费在JVM或OS代码上的时间相对多了,而花 费在程序代码上的时间则相对少了。而且,争用还削弱了可伸缩性,因为它迫使调度程序把操作序列化,即使有可用的空闲处理器也是如此。当一个线程正在执行一 个同步块时,任何等待进入该块的线程都将被阻塞。如果没有其他的线程可供执行,那么处理器就将空闲。
如果想编写具可伸缩性的多线程 程序,我们就必须减少对临界资源的争用。有很多技术可以做到这一点,但在应用它们之前,您需要仔细研究一下您的代码,判断出在什么情况下您需要在公共管程 上同步。判断哪些锁是瓶颈很困难:有时候锁隐藏在类库中,有时候又通过同步方法隐式地指定,因此在阅读代码时,锁并不那么明显。而且,目前的争用检测工具 也很差。

 

减少争用的方法

①“放进去,取出来”——使同步块尽可能小

使同步块尽可能小显然是降低争用可能性的一种技术。一个线程占用一个给定锁的时间越短,另一个线程在该线程仍占用锁时请求该锁的可能性就越小。因此在您应该使用同步去访问或更新共享变量时,在同步块的外面进行线程安全的预处理或后处理通常会更好些。
另一方面,有可能会过度使用这种技术。要是您想用一小块线程安全代码把要求同步的两个操作隔开,那么只使用一个同步块一般会更好些。


 

 

 

 

②减小锁的粒度

把您的同步分散在更多的锁上是减少争用的另一种有价值的技术。
如 清单3所示的例子,虽然这样做是完全线程安全的,但却增加了毫无实际意义的争用可能性。如果一个线程正在执行setUserInfo ,就不仅意味着其它线程将被锁在 setUserInfo 和 getUserInfo 外面(这是我们希望的),而且意味着它们也将被锁在 getServiceInfo 和 setServiceInfo 外面。通过使访问器只在共享的实际对象( userMap 和 servicesMap 对象)上同步可以避免这个问题,如清单4所示。


 

 
现 在,访问服务 map(servicesMap)的线程将不会与试图访问用户 map(usersMap)的线程发生争用。(在这种情况下,通过使用 Collections 框架提供的同步包装机制,即 Collections.synchronizedMap 来创建 map 可以达到同样的效果。)假设对两个 map 的请求是平均分布的,那么这种技术在这种情况下将把可能的争用数目减半。

 

③锁崩溃

另一种能提高性能的技术称为“锁崩溃”(请参阅清单 6)。回想一下, Vector 类的方法几乎都是同步的。假设您有一个 String 值的Vector ,并想搜索最长的 String 。进一步假设您已经知道只会在末端添加元素,而且元素不会被删除,那么,像 getLongest() 方法所展示的那样访问数据是安全的(通常),该方法只是调用 elementAt() 来检索每个元素,简单地对Vector 的元素作循环。
getLongest2()方法非常相似,除了在开始循环之前 获取Vector上的锁之外。这样做的结果是当elementAt()试图获取锁时,JVM 将注意到当前线程已经拥有锁,而且将不会参与争用。getLongest2()加大了同步块,这似乎违背了“放进去,取出来”的原则,但因为避免了很大量 可能的同步,调度开销的时间损失也少了,速度仍然快得多。


 

摘抄自IBM developerWorks上的文章——《轻松使用线程》系列:

轻松使用线程1: 同步不是敌人
轻松使用线程2: 减少争用
轻松使用线程3: 不共享有时是最好的

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics