![6dc6439f3c12f63dba89ee6121fed562.png](https://i-blog.csdnimg.cn/blog_migrate/80eb7685aa3d8c7bda0b0506885c46ed.jpeg)
前言
我们前面说过了,多线程并发编程中必然存在资源的竞争,这就需要同步锁来处理。
但是,当两个或者以上的线程早竞争一个资源锁时会引入额外的锁周期,由于存在竞争,所以就可能需要强迫锁调度器来让一个线程自旋等待锁,或者通过上下文的切换让另一个线程占用处理器。
这些都会使得同步锁成为影响应用程序性能的因素。所以,通常我们为提高多线程并发应用程序的性能,会尽量的减低对竞争锁的使用。
在某些情况下,可以通过应用以下技术之一来减少锁争用:
- 减小锁定代码范围。
- 减少获取某个锁的次数。
- 使用硬件支持的乐观锁操作,而不是同步。
- 尽可能避免同步
- 避免对象池
减小被锁定的范围
第一种技术可以应用于锁持有时间超过必要的情况。
通常可以通过将一个或多个行移出同步块来减少当前线程持有锁的时间。
要执行的代码行数越少,当前线程就可以越早地离开同步块,从而让其他线程获得锁。
这也符合Amdahl定律,因为我们减少了运行时花费在同步块上的时间。
为了更好地理解这种技术,看看下面的源代码:
![3201366430c706cd1a85ab21e103cc46.png](https://i-blog.csdnimg.cn/blog_migrate/bad47d4b2d71d0467cd8ecfcc002505b.jpeg)
执行结果:
![0ba2660d4dc37cafdfee84cd0f591d1a.png](https://i-blog.csdnimg.cn/blog_migrate/c478c09c0e19db2a3010bef5a34fcb7d.jpeg)
在这个示例应用程序中,我们让5个线程竞争共享Map的访问权。
保证一次只让一个线程访问Map并添加新的键/值对的代码被放入同步块中。
当我们仔细查看这个块时,我们会发现键的计算以及原整数58到整数对象的转换必须是非同步的。
它们在概念上属于访问Map的代码,但是它们在当前线程中是本地的,并且实例不会被其他线程修改。
因此,我们可以把他们移出同步块:
![188c2bfeca8f0eb1f9bd0f603b7b4015.png](https://i-blog.csdnimg.cn/blog_migrate/cf6b29ccda84ee3bd9f6c1a324dd6093.jpeg)
执行结果:
![3edfc57da7b266d95535efbf220fd614.png](https://i-blog.csdnimg.cn/blog_migrate/ab3ebdb27950365409d8675ad4191722.jpeg)
同步块的减少对可以测量的运行时有影响。在我的机器上,整个应用程序的运行时间从775ms减少到683ms,同步块最小化。
也就是说,仅通过将三行代码移出同步块,就可以减少约11.9%的运行时间。
代码中语句thread. yield()这里被引入来触发更多的上下文切换,因为这个方法调用告诉JVM当前线程愿意将处理器CPU交给另一个等待的线程使用。
但是要注意,这种上下文切换会带来性能的消耗,并引发了更多的锁争用,否则一个线程可能在没有任何竞争线程的情况下在处理器上运行太长时间。
![1a8e7e1ed15e5448646a44de12abb040.png](https://i-blog.csdnimg.cn/blog_migrate/a552c3ade7d364c38389d67dbe003891.jpeg)
分裂锁
减少锁争用的另一种技术是将一个锁分割成许多较小的作用域锁。如果我们需要有一个用于保护应用程序的不同方面的锁,则可以应用此技术。
假设我们希望收集关于应用程序的一些统计数据,并实现一个简单的计数器类,该类为每个方面保存一个原始计数器变量。
其接口定义如下:
![3e0d34d7ba7cb0b409600a79802edb56.png](https://i-blog.csdnimg.cn/blog_migrate/7203b4d7a829e6e63dd498add2e30b57.jpeg)
由于我们的应用程序是多线程的,我们必须同步对这些变量的访问,因为它们是从不同的并发线程访问的。
最简单的方法是在方法签名中为每个方法counter使用synchronized关键字:
![bf5f3007967715a5f2b69e7d92c88202.png](https://i-blog.csdnimg.cn/blog_migrate/ed5f454d37726c8c654a16899f1f9c4a.jpeg)
此方法意味着计数器的每个增量都将锁定计数器的整个实例。其他希望增加不同变量的线程必须等待,直到释放这个锁。
在这种情况下,更有效的是为每个计数器使用单独的锁,就像下面的例子:
![f39996372378b98890739216270d2103.png](https://i-blog.csdnimg.cn/blog_migrate/11e09c7e0cd1bea9b5f019e5e3a8fc2a.jpeg)
这个实现引入了两个独立的同步对象,每个计数器一个。
因此,一个试图在我们的系统中增加客户数量的线程只需要与其他也增加客户数量的线程竞争,但它不必与试图增加购物数量的线程竞争。
通过使用下面的类,我们可以很容易地测量这种锁分裂的影响:
![0b20a2b565a42eeedd2fb84664835c39.png](https://i-blog.csdnimg.cn/blog_migrate/ee18d2af290c2fcc056cb328638f8663.jpeg)
在我的机器上,使用一个锁的实现平均需要大约56毫秒,而使用两个单独锁的实现大约需要38毫秒。减少了大约32%。
另一个可能的改进是通过区分读锁和写锁来进一步分离锁。
例如,Counter类提供了读取和写入计数器值的方法。
虽然可以由多个线程并行地读取当前值,但是所有的写操作都必须序列化。
java.util.concurrent包就提供这样一个ReadWriteLock的可用实现:ReentrantReadWriteLock。
ReentrantReadWriteLock实现管理两个单独的锁。
一个用于读访问,一个用于写访问。读锁和写锁都提供了锁定和解锁的方法。
只有在没有读锁的情况下,才会获得写锁。读锁可以由多个读线程获取,只要不获取写锁。
为了演示,下面展示了使用ReadWriteLock的counter类的实现:
![8d43a19676ee46b29a370e2d697d4c30.png](https://i-blog.csdnimg.cn/blog_migrate/19e5827c9859db1f39cb4745a35449a9.jpeg)
所有读访问都由获取的读锁保护,而所有写访问都由相应的写锁保护。
如果应用程序使用的读访问比写访问多得多,那么这种实现甚至可以比前一种实现获得更多的性能改进,因为所有读线程都可以并行访问getter方法。
![011ef3f131536b3e1a9f7140c33fb76b.png](https://i-blog.csdnimg.cn/blog_migrate/55ce3cf8758e28317e91a8b0390e3a4b.jpeg)
分段锁
前面的示例演示了如何将一个锁拆分为两个单独的锁。
它们允许竞争线程只获取保护它们想要操作的数据结构的锁。
另一方面,如果没有正确地实现,这种技术也会增加复杂性和死锁的风险。
而分段锁是一种类似于锁分割的技术。我们不使用一个锁来保护不同的代码部分或内容,而是为不同的值使用不同的锁。
JDK的 java.util.concurrent包的实现内容就使用了该技术来使用此技术来改进严重依赖HashMap的应用程序的性能。
与“java.util.HashMap”的同步版本相比起来, ConcurrentHashMap使用16个不同的锁。
每个锁只保护可用哈希桶的1/16。
这允许希望将数据插入可用哈希桶的不同部分的不同线程并发地执行此操作,因为它们的操作由不同的锁保护。
另一方面,它也引入了为特定操作获取多个锁的问题。例如,如果我们想复制整个Map,必须获得所有16个锁。
原子操作
另一种减少锁争用的方法是使用所谓的原子操作。
java.util.concurrent提供对某些基本数据类型的原子操作的支持。
我们知道原子操作是使用处理器提供的所谓比较和交换(CAS)操作来实现的。如果当前值等于提供的值,则CAS指令仅更新特定寄存器的值。只有在这种情况下,旧值才会被新值替换。
这个原则可以用来以乐观的方式修改一个变量。如果我们假设线程知道当前值,那么它可以尝试使用CAS操作修改当前值。
当另一个线程同时增加了值,而我们线程的值不再是当前值,我们请求当前值并再次尝试,直到我们成功地增加计数器。
这个实现的优点是尽管我们可能需要一些线程自旋,但我们不需要任何类型的同步锁定。
下面的代码使用了原子变量方法来实现计数器类,注意这里没有使用任何同步块:
![a28f97cce3897499926920b97f4bfaeb.png](https://i-blog.csdnimg.cn/blog_migrate/f6e8611e9a3bb8daa85269e8695a7417.jpeg)
与CounterSeparateLock类相比,总的平均运行时间从42ms减少到18ms。运行时减少了大约57%。
避免热点
列表的典型实现将在内部管理一个计数器,该计数器保存列表中的项数。每当向列表中添加新项或从列表中删除新项时,此计数器都会进行更新。
如果在单线程应用程序中使用,这种优化是合理的,因为列表中的size()操作将直接返回先前计算的值。
如果列表不包含列表中的项数,那么size()操作必须遍历所有项才能计算它。
在许多数据结构中常见的优化在多线程应用程序中可能成为一个问题。
假设我们希望与一群线程共享这个列表的一个实例,这些线程插入和删除列表中的项并查询其大小。
计数器变量现在也是共享资源,对其值的所有访问都必须同步。
该计数器已成为实现内部的热点。
下面的代码片段演示了这个问题:
![615caf2dec1631dd20ff70442ba81ed0.png](https://i-blog.csdnimg.cn/blog_migrate/a0578f7bf2ac125b33634b4c3ff471cd.jpeg)
ItemRepository实现包含两个列表:一个用元素名以"S"开头的,另一个用于存储其余的元素。
它还提供了一个方法来返回当前在这两个列表中的各项元素的数量。每当一个新元素都被添加到这两个列表之一时,它都会增加内部计数器。
注意这个操作必须与专用的itemCountSync实例同步。在返回计数值时使用相同的同步。
要省掉这种额外的同步,ItemRepository也可以通过省略额外的计数器和计算每次该值被调用getItemCount()时的总元素数量来实现:
![c9b22f7310e4bb6d4bb69f3ab2a25de7.png](https://i-blog.csdnimg.cn/blog_migrate/be14bf2debb38965340380e2e3cddb25.jpeg)
现在,我们需要与getItemCount()方法中的itemMap1和itemMap2列表进行同步,并计算大小,但是可以忽略添加元素期间的额外同步。
![a1070a219fda64e44b15f903b365a4f2.png](https://i-blog.csdnimg.cn/blog_migrate/abdb1b484e6510fe58bdcd95c1d7755d.jpeg)
避免对象池
在Java VM的最初版本中,使用新操作符创建对象仍然是一个昂贵的操作。
这使得许多程序员采用了对象池的通用模式。
他们不是一次又一次地创建某些对象,而是构建这些对象的一个池,每次需要一个实例时,就从池中取出一个。
在使用对象之后,它被放回池中,可以由其他线程使用。
在多线程应用程序中使用时,看上去合理的可能是一个问题。
现在对象池在所有线程之间共享,并且必须同步对池中对象的访问。这个额外的同步开销可能要比对象创建本身的成本更大。
当我们再考虑到垃圾收集器收集新创建的对象实例的额外成本时,这个开销会更大。
总结
本文我们详细介绍了一些有关锁对于多线程应用程序的性能影响,也介绍了几种可以降低这种影响的方法,与所有的性能优化一样,我们在应用之前应该仔细衡量每一个可能的改进,来选择具体的实现方案,从而将他们对应用程序性能的影响降低到最小。