十分钟带你深入了解多线程——多线程关于锁的优化(一)

简介: 十分钟带你深入了解多线程——多线程关于锁的优化(一)

一、有助于提高锁性能的几点建议

锁的竞争必然会导致程序的整体性能下降。为了将这种副作用降到最低,这里提出一些关于使用锁的建议,希望可以帮助大家写出性能更高的程序。

1、减少锁持有时间

对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间越长,那么相对地,锁的竞争程度也就越激烈。可以想象一下,如果要求100 个人各自填写自己的身份信息,但是只给他们一支笔,那么如果每个人拿着笔的时间都很长,总体所花的时间就会很长。如果真的只有一支笔共享给100个人用,那么最好让每个人花尽量少的时间持笔,务必做到想好了再拿笔写,千万不能拿着笔才去思考这表格应该怎么填。程序开发也是类似的,应该尽可能地减少对某个锁的占有时间,以减少线程间互斥的可能。以下面的代码段为例:

public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}

在syncMethod()方法中,假设只有mutextMethod()方法是有同步需要的,而othercode1(方法和othercode2()方法并不需要做同步控制。如果 othercode1()和 othercode2()分别是重量级的方法,则会花费较长的CPU时间。如果在并发量较大时,使用这种对整个方法做同步的方案,则会导致等待线程大量增加。因为一个线程,在进入该方法时获得内部锁,只有在所有任务都执行完后,才会释放锁。
一个较为优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量。

public void syncMethod2 (){
othercode1();
synchronized (this){
mutextMethod ();
}
othercode2();
}

在改进的代码中只针对mutextMethod()方法做了同步,锁占用的时间相对较短,因此能有更高的并行度。这种技术手段在JDK的源码包中也可以很容易地找到,比如处理正则表达式的Pattern类。

public Matcher matcher(CharSequence input) {
if (!compiled){
synchronized (this){
if (!compiled)
compile();
}
}
Matcher m= new Matcher (this, input);
return m;
}

matcher()方法有条件地进行锁申请,只有在表达式未编译时,进行局部的加锁。这种处理方式大大提高了matcher(方法的执行效率和可靠性。
注意:减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

2、减小锁粒度

减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现。
对于HashMap来说,最重要的两个方法就是get()和 put()。一种最自然的想法就是,对整个HashMap加锁从而得到一个线程安全的对象,但是这样做,加锁粒度太大。对于ConcurrentHashMap类,它内部进一步细分了若干个小的HashMap,称之为段(SEGMENT)。在默认情况下,一个ConcurrentHashMap类可以被细分为16个段。
如果需要在ConcurrentHashMap类中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()方法操作。在多线程环境中,如果多个线程同时进行 put()方法操作,只要被加入的表项不存放在同一个段中,线程间便可以做到真正的并行。
由于默认有16个段,因此,如果够幸运的话,ConcurrentHashMap类可以接受16个线程同时插入(如果都插入不同的段中),从而大大提升其吞吐量。下面代码显示了 put()方法操作的过程。第5~6行代码根据key 获得对应段的序号。接着在第9行得到段,然后将数据插入给定的段中。

public v put(K key,v value) {
Segment<K,V> S;
if (value -= null)
throw new NullPointerException();
int hash = hash (key);
int j =(hash >>> segmentShift) & segmentMask;
if((s= (Segment<K, V>)UNSAFE.getObject
(segments,(j<<SSHIFT)+SBASE)) -= null)s =ensureSegment(j);
return s.put (key, hash,value, false);
}

但是,减小锁粒度会带来一个新的问题,即当系统需要取得全局锁时,其消耗的资源会比较多。仍然以ConcurrentHashMap类为例,虽然其 put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap类的全局信息时,就需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap类的size()方法,它将返回ConcurrentHashMap类的有效表项的数量,即 ConcurrentHashMap类的全部有效表项之和。要获取这个信息需要取得所有子段的锁,因此,其 size()方法的部分代码如下:

sum= 0;
for (int i = 0; i<segments.length; ++i)
//对所有的段加锁
segments[i].lock();
for (int i =0; i< segments.length; ++i)
//统计总数
sum +=segments[i].count;
for (int i =0; i<segments.length; ++i)
//释放所有的锁
segments[i].unlock();

可以看到在计算总数时,先要获得所有段的锁再求和。但是,ConcurrentHashMap类的size()方法并不总是这样执行的,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方法。但不管怎么说,在高并发场合ConcurrentHashMap类的size()方法的性能依然要差于同步的HashMap。
因此,只有在类似于 size()方法获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能在真正意义上提高系统的吞吐量。
注意:所谓减小锁粒度,就是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。

3、用读写分离锁来代替独占锁

之前我们已经提过,使用读写分离锁ReadWriteLock可以提高系统的性能。使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割。
在读多写少的场合,读写锁对系统性能是很有好处的。因为如果系统在读写数据时均只使用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能做到真正的并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性。因此,从理论上讲,在大部分情况下,可以允许多线程同时读,读写锁正是实现了这种功能。由于我们在第3章中已经介绍了读写锁,因此这里就不再重复了。
注意:在读多写少的场合使用读写锁可以有效提升系统的并发能力。

4、锁分离

如果将读写锁的思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现。
在LinkedBlockingQueue 的实现中,take()函数和 put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。
如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()方法和put()方法就不可能真正的并发,在运行时,它们会彼此等待对方释放锁资源。在这种情况下,锁竞争会相对比较激烈,从而影响程序在高并发时的性能。
因此,在JDK的实现中,并没有采用这样的方式,取而代之的是用两把不同的锁分离了take()方法和 put)方法的操作。

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();//take()方法需要持有takeLock/**ait queue for waiting takes * /
private final Condition notEmpty - takeLock.newCondition ();/** Lock held by put,offer,etc*/
private final ReentrantLock putLock = new ReentrantLock();//put()方法需要持有putLock./**wait queue for waiting puts */
private final Condition notFull= putLock.newCondition();

以上代码片段定义了takeLock和 putLock,它们分别在 take()方法和 put()方法中使用。因此,take()方法和 put()方法就此相互独立,它们之间不存在锁竞争关系,只需要在take()方法和 take()方法间、put()方法和put()方法间分别对takeLock和 putLock进行竞争。从而,削弱了锁竞争的可能性。
take()方法的实现如下,笔者在代码中给出了详细的注释,故不在正文中做进一步说明了。

public E take()throws InterruptedException{
Ex;
int c - -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;takeLock. lockInterruptibly();
//不能有两个线程同时取数据
try {
try {
while (count.get( =0)
//如果当前没有可用数据,则一直等待
notEmpty.await ();
//等待put()方法操作的通知
] catch (InterruptedException ie){
notEmpty.signal ();
//通知其他未中断的线程
throw ie;
x=extract();
//取得第一个数据
C= count.getAndDecrement (O;
//数量减1,原子操作,因为会和put ()//函数同时访问count。注意:变量c是//count减1前的值
if(c >1)
notEmpty.signal ();
//通知其他take()方法操作
} finally {
takeLock.unlock();
//释放锁
if(c -= capacity)
signalNotFul1(0);
//通知put()方法操作,已有空余空间
return x;

函数put()的实现如下。

public void put(Ee) throws InterruptedException{
if (e -= null)throw new NullPointerException();int c= -1;
final ReentrantLock putLock = this.putLock;final AtomicInteger count = this.count;putLock.lockInterruptiblyO;
//不能有两个线程同时进行put()方法
try {
try {
while (count.get( -=capacity)
//如果队列已经满了
notFull.await(;
//等待
}catch (InterruptedException ie) {
notFull.signal();
//通知未中断的线程
throw ie;
insert(e);
//插入数据
C=count.getAndIncrement ();
//更新总数,变量c是count加1前的值
if (c+1< capacity)
notFull.signal ();
//有足够的空间,通知其他线程
}finally{
putLock.unlock();
//释放锁
if (c ==0)
signalNotEmpty();//插入成功后,通知take ()方法取数据
}

通过takeLock和putLock 两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。

4、锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
为此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫作锁的粗化。

public void demoMethod () {
synchronized(lock){
//do sth.
)
//做其他不需要的同步的工作,但能很快执行完毕synchronized(lock){
//do sth.

上面的代码段会被整合成如下形式;

public void demoMethod({
//整合成一次锁请求synchronized (lock){
//do sth.
//做其他不需要的同步的工作,但能很快执行完毕
)

在开发过程中,大家也应该有意识地在合理的场合进行锁的粗化,尤其当在循环内请求锁时。以下是一个循环内请求锁的例子,在这种情况下,意味着每次循环都有申请锁和释放锁的操作。但在这种情况下,显然是没有必要的。

for(int i=0;i<CIRCLE;i++){
synchronized (lock){
}}

所以,一种更加合理的做法应该是在外层只请求一次锁:

synchronized (lock){
for(int i=0;i<CIRCLE;i++){
)
)

注意:性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同,因此要根据实际情况进行权衡。

摘自JAVA高并发程序设计,推荐推荐

相关文章
|
27天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
19 3
|
27天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
17 2
|
27天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
29 2
|
27天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
33 1
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
44 1
C++ 多线程之初识多线程
|
27天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
34 1
|
27天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
25 1
|
2月前
|
数据采集 负载均衡 安全
LeetCode刷题 多线程编程九则 | 1188. 设计有限阻塞队列 1242. 多线程网页爬虫 1279. 红绿灯路口
本文提供了多个多线程编程问题的解决方案,包括设计有限阻塞队列、多线程网页爬虫、红绿灯路口等,每个问题都给出了至少一种实现方法,涵盖了互斥锁、条件变量、信号量等线程同步机制的使用。
LeetCode刷题 多线程编程九则 | 1188. 设计有限阻塞队列 1242. 多线程网页爬虫 1279. 红绿灯路口
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
47 6
|
1月前
|
存储 运维 NoSQL
Redis为什么最开始被设计成单线程而不是多线程
总之,Redis采用单线程设计是基于对系统特性的深刻洞察和权衡的结果。这种设计不仅保持了Redis的高性能,还确保了其代码的简洁性、可维护性以及部署的便捷性,使之成为众多应用场景下的首选数据存储解决方案。
41 1