Java并发编程 -- 再论锁的问题 -- 无锁与锁优化

时间:2022-08-07 12:08:43

在前面JUC源码分析和Disruptor分析序列中,我们已经反复讨论了锁与无锁的问题。

众所周知,在多线程程序中,锁是性能杀手。因此“锁优化”一直是多线程中被频繁探讨的一个问题。

本文将从“锁优化”这个应用层面,把前面的诸多东西串起来,探讨一下锁优化的一系列策略。

策略1:业务和设计层面 – 单线程或去共享资源

我们知道,至所以要加锁,是因为多线程 + 共享资源。

如果我们可以根据具体业务场景,或者从顶层设计层面,不用多线程,或者去掉共享资源,那不就可以无锁了吗?

这个策略呢,不是一个纯粹的Java的技术问题,没有标准答案,也没有放之四海皆准的一个原则。所以最难掌握,但往往是最关键的。

下面举几个这样的例子:

单线程

我们知道,Redis就是单线程模型,与之相反的是Memcached的多线程模型。个人没看过Redis的源码,但从这个一般描述可以看出,Redis肯定比Memcached所面对的锁的问题要简化很多。

关于Redis与Memcached在这个问题上的对比,笔者也写了篇文章。参见:
http://blog.csdn.net/chunlongyu/article/details/53346436#t7

ThreadLocal

ThreadLocal就是一个典型的去掉共享资源的办法。本来是多个线程,共享一个对象,必然要加锁。

那如果我为每个线程准备一个对象的拷贝,各访问各的,不就可以无锁了!

这个的典型例子就是SimpleDateFormat,我们知道这个类是非线程安全的,要在多线程中访问,一个办法就是加ThreadLocal。关于这个的代码,网上很多,此处就不列举了。

策略2: single-writer principle – volatile

在前面的序列文章中,我们已经提到过Linux内核的kfifo队列,Disruptor中的RingBuffer都是完全无锁的。

至所以他们可以做到,有一个前提条件,就是:单线程写。

只要是单线程写,1写1读,或者1写多读,不是多写多读。那就可以不加锁,通过volatile变量,实现内存的可见性。

关于这个的详情,参见前面的Disruptor的序列分析:
http://blog.csdn.net/chunlongyu/article/details/53304524

策略3: 乐观锁 – CAS

相对悲观锁,乐观锁通过CAS + 自旋实现,而不调用底层pthread的mutex对象,更加轻量。比直接使用悲观锁要更优。

关于CAS + 自旋,其实不管是synchronized关键字的实现,还是JUC中lock的实现,都用了此种技术。

当然,在这个前面,还有一个策略,就是“偏向锁”,专门处理单线程多次调用加锁代码的优化。

策略4:锁细化

锁细化,也就是降低锁的粒度,提供并发读。

ConcurrentHashMap就是锁细化的典型例子;Mysql中InnoDB的行锁,相对MyIsam的表锁,也是一个典型例子。

策略5:锁粗化

与锁细化刚好相反,有时候,我们需要提高锁的粒度。

比如在一个for循环里面,不断加锁/解锁,那还不如把锁拿到for循环外面,只加1次/解锁1次;

再比如一个函数里面,有2段代码,分别对同1把锁,加锁/解锁,那可能还不如把2段代码放到1个里面。

策略6: 锁分离

也就是前面说的读写锁,同1把锁分离成读锁 + 写锁。此处不再详述。

策略7:锁消除与逃逸分析

锁消除不是应用层代码做的事情,而是编译器做的事情。

所谓逃逸分析,就是编译器发现一个内部有锁的对象,比如StringBuffer。它只在某1个函数内部使用,函数外部没有其他地方用它,那就是这个对象没有逃狱到函数外面,意味着这个对象只可能单线程访问,那就可以去掉锁。

对应的参数是
-xx:+DoEscapeAnalysis -xx:+EliminateLocks