在多线程编程中,锁是实现线程同步的重要手段。然而,锁的不当使用会导致锁竞争,进而降低并发性能。本节我们就来探讨如何减少锁竞争,提高并发性能,并通过 Java 代码演示具体的优化方法。
锁竞争的原因
在深入了解锁优化策略之前,我们需要先明白锁竞争是怎么产生的。锁竞争本质上是多个线程同时尝试获取同一个锁,从而导致线程阻塞等待。以下是一些常见的导致锁竞争的原因:
锁的粒度太大:当一个锁所保护的代码块范围过大时,会有更多的线程在该代码块上竞争锁。比如,在一个大型的业务逻辑中,如果将整个业务逻辑都用一个锁来保护,那么只要有一个线程在执行这个业务逻辑,其他线程就都需要等待。打个比方,一家餐厅只有一个服务员负责所有的点餐、上菜、结账等工作,那么顾客们就需要排着队等待这个服务员的服务,效率自然就低了。
锁的持有时间过长:如果一个线程持有锁的时间过长,那么其他线程就需要更长的时间来等待获取锁。例如,在一个数据库操作中,如果在持有锁的情况下进行了大量的数据库查询和更新操作,而数据库操作通常比较耗时,那么其他线程就会在锁的等待队列中等待很久。这就好像一个人在厕所里待了很长时间,外面的人都要等得不耐烦了。
高并发场景:在高并发的情况下,多个线程同时请求同一个锁的概率会大大增加。比如,在电商平台的促销活动期间,大量用户同时下单,服务器上的多个线程就会同时竞争订单处理的锁,从而导致严重的锁竞争。
常见锁优化策略
针对上述锁竞争的原因,我们可以采用以下常见的锁优化策略。
减小锁的粒度
减小锁的粒度就是将一个大的锁拆分成多个小的锁,让不同的线程可以同时访问不同的锁保护的代码块,从而减少锁竞争。
- 分段锁:分段锁是一种典型的减小锁粒度的方法。以 Java 中的
ConcurrentHashMap为例,它内部使用了分段锁来提高并发性能。ConcurrentHashMap将整个哈希表分成了多个段,每个段都有自己独立的锁。当多个线程访问不同的段时,它们可以同时进行操作,而不需要竞争同一个锁。就像餐厅里有多个服务员,每个服务员负责一部分区域的顾客服务,这样顾客们就可以同时得到服务,效率就提高了。
以下是一个简单的分段锁示例代码:
importjava.util.HashMap;importjava.util.Map;// 自定义分段锁实现classSegmentLock<K,V>{privatestaticfinalintSEGMENT_COUNT=16;privatefinalSegment<K,V>[]segments;@SuppressWarnings("unchecked")publicSegmentLock(){segments=newSegment[SEGMENT_COUNT];for(inti=0;i<SEGMENT_COUNT;i++){segments[i]=newSegment<>();}}privateintgetSegmentIndex(Kkey){returnMath.abs(key.hashCode())%SEGMENT_COUNT;}publicvoidput(Kkey,Vvalue){intindex=getSegmentIndex(key);segments[index].put(key,value);}publicVget(Kkey){intindex=getSegmentIndex(key);returnsegments[index].get(key);}staticclassSegment<K,V>{privatefinalMap<K,V>map=newHashMap<>();privatefinalObjectlock=newObject();publicvoidput(Kkey,Vvalue){synchronized(lock){map.put(key,value);}}publicVget(Kkey){synchronized(lock){returnmap.get(key);}}}}publicclassSegmentLockExample{publicstaticvoidmain(String[]args){SegmentLock<String,Integer>segmentLock=newSegmentLock<>();segmentLock.put("key1",1);segmentLock.put("key2",2);System.out.println(segmentLock.get("key1"));}}在这个示例中,我们自定义了一个分段锁SegmentLock,将数据分成了 16 个段,每个段都有自己的锁。不同的线程可以同时访问不同的段,从而提高了并发性能。
减少锁的持有时间
减少锁的持有时间可以让其他线程更快地获取锁,从而减少锁竞争。
- 将不必要的操作移出同步块:在同步块中只包含必要的代码,将那些不需要同步的操作移到同步块外面。例如,在一个方法中,如果有一些数据的计算操作不需要同步,那么就可以将这些计算操作放在同步块之前或之后进行。
以下是一个示例代码:
publicclassReduceLockHoldTimeExample{privatefinalObjectlock=newObject();privateintcount=0;publicvoidincrement(){// 不需要同步的操作inttemp=doSomeCalculation();synchronized(lock){// 只在需要同步的操作上加锁count++;}// 不需要同步的操作doSomeOtherCalculation(temp);}privateintdoSomeCalculation(){// 模拟一些计算操作return1;}privatevoiddoSomeOtherCalculation(inttemp){// 模拟一些计算操作}}在这个示例中,我们将不需要同步的计算操作doSomeCalculation()和doSomeOtherCalculation()放在了同步块之外,只在需要同步的count++操作上加了锁,从而减少了锁的持有时间。
锁分离
锁分离是指将不同类型的操作使用不同的锁来保护,从而减少锁竞争。
- 读写锁:读写锁是一种典型的锁分离技术。在 Java 中,
ReentrantReadWriteLock就是一个读写锁的实现。读写锁允许多个线程同时进行读操作,但在写操作时需要独占锁。这样,当多个线程只进行读操作时,它们可以同时访问共享资源,而不需要竞争锁;只有当有线程进行写操作时,才会阻塞其他线程的读和写操作。
以下是一个读写锁的示例代码:
importjava.util.concurrent.locks.ReentrantReadWriteLock;publicclassReadWriteLockExample{privatefinalReentrantReadWriteLockrwLock=newReentrantReadWriteLock();privatefinalReentrantReadWriteLock.ReadLockreadLock=rwLock.readLock();privatefinalReentrantReadWriteLock.WriteLockwriteLock=rwLock.writeLock();privateintdata=0;publicintreadData(){readLock.lock();try{returndata;}finally{readLock.unlock();}}publicvoidwriteData(intnewData){writeLock.lock();try{data=newData;}finally{writeLock.unlock();}}}在这个示例中,我们使用ReentrantReadWriteLock来实现读写锁。多个线程可以同时调用readData()方法进行读操作,而当有线程调用writeData()方法进行写操作时,其他线程的读和写操作都会被阻塞。
实操模块:Java 代码优化演示
以下是一个综合的 Java 代码示例,演示如何使用上述锁优化策略来减少锁竞争,提高并发性能。
importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.TimeUnit;// 未优化的代码classUnoptimizedCounter{privatefinalObjectlock=newObject();privateintcount=0;publicvoidincrement(){synchronized(lock){// 模拟一些耗时操作try{TimeUnit.MILLISECONDS.sleep(1);}catch(InterruptedExceptione){e.printStackTrace();}count++;}}publicintgetCount(){synchronized(lock){returncount;}}}// 优化后的代码classOptimizedCounter{privateint[]counts=newint[16];privatefinalObject[]locks=newObject[16];publicOptimizedCounter(){for(inti=0;i<16;i++){locks[i]=newObject();}}publicvoidincrement(){intindex=(int)(Thread.currentThread().getId()%16);synchronized(locks[index]){// 模拟一些耗时操作try{TimeUnit.MILLISECONDS.sleep(1);}catch(InterruptedExceptione){e.printStackTrace();}counts[index]++;}}publicintgetCount(){inttotal=0;for(inti=0;i<16;i++){synchronized(locks[i]){total+=counts[i];}}returntotal;}}publicclassLockOptimizationExample{publicstaticvoidmain(String[]args)throwsInterruptedException{intthreadCount=10;intincrementCount=100;// 测试未优化的代码UnoptimizedCounterunoptimizedCounter=newUnoptimizedCounter();ExecutorServiceunoptimizedExecutor=Executors.newFixedThreadPool(threadCount);longunoptimizedStartTime=System.currentTimeMillis();for(inti=0;i<threadCount;i++){unoptimizedExecutor.submit(()->{for(intj=0;j<incrementCount;j++){unoptimizedCounter.increment();}});}unoptimizedExecutor.shutdown();unoptimizedExecutor.awaitTermination(1,TimeUnit.MINUTES);longunoptimizedEndTime=System.currentTimeMillis();System.out.println("未优化代码执行时间: "+(unoptimizedEndTime-unoptimizedStartTime)+" 毫秒");System.out.println("未优化代码最终计数: "+unoptimizedCounter.getCount());// 测试优化后的代码OptimizedCounteroptimizedCounter=newOptimizedCounter();ExecutorServiceoptimizedExecutor=Executors.newFixedThreadPool(threadCount);longoptimizedStartTime=System.currentTimeMillis();for(inti=0;i<threadCount;i++){optimizedExecutor.submit(()->{for(intj=0;j<incrementCount;j++){optimizedCounter.increment();}});}optimizedExecutor.shutdown();optimizedExecutor.awaitTermination(1,TimeUnit.MINUTES);longoptimizedEndTime=System.currentTimeMillis();System.out.println("优化后代码执行时间: "+(optimizedEndTime-optimizedStartTime)+" 毫秒");System.out.println("优化后代码最终计数: "+optimizedCounter.getCount());}}在这个示例中,我们定义了两个计数器类UnoptimizedCounter和OptimizedCounter。UnoptimizedCounter使用一个全局锁来保护计数器,而OptimizedCounter使用了分段锁的思想,将计数器分成了 16 个部分,每个部分都有自己的锁。通过对比这两个计数器的执行时间,我们可以看到优化后的代码在并发性能上有了显著的提升。
总结
通过本节的学习,我们了解了锁竞争的原因,掌握了常见的锁优化策略,如减小锁的粒度、减少锁的持有时间和锁分离等,并通过 Java 代码演示了如何应用这些策略来减少锁竞争,提高并发性能。掌握了这些内容后,下一节我们将深入学习线程分析的方法,进一步完善对本章 JVM 中线程和锁优化主题的认知。
🍃 系列专栏导航
- 🔖 《JVM调优》
- 🍃 博客概览:《程序员技术成长导航,专栏汇总》