不堆概念、换个角度聊多线程并发编程

2022-08-05 14:10:03 浏览数 (1)

大家好,又见面了。

在上一篇文档《JAVA基于CompletableFuture的流水线并行处理深度实践,满满干货》中,我们一起探讨了JAVA中并行编码的相关内容,在文中也一起比较了并行与并发的区别。作为姊妹篇,这里我们就再展开聊一聊关于并发相关的内容。

image.png

俗话说,双拳难敌四手。 俗话还说,人多力量大

在现实生活中,我们通过团队化的方式来获得比单兵作战更高的单位时间内整体产出速度。同样,在编码世界中,为了提升处理效率,并发一直以来都是软件开发设计场景中无法绕过的话题。不管是微观层面的单个进程内多线程处理模式,还是宏观层面整个系统集群化多节点部署策略,为了提升系统的整体并发吞吐量,程序员们可谓是煞费苦心。

image.png

当然,俗话也说,人多眼杂林子大了什么鸟都有。在现实中,团队中多人一起配合工作的时候,一系列的问题又会显现:

  • 同一个事情,老王和小张都以为还没处理,结果都去处理了,最后造成了成员工作量的浪费、甚至因为重复处理了一遍导致数据错误
  • 两个有关联的事情分别给了老王和翠花,结果老王在等待翠花先给出结果再开始处理自己的事情,翠花也在等待老王先给出结果然后再处理自己的事情,结果两个人就这么一致等下去,事情一直没完成
  • 同一个文档,小张和翠花各自更新的时候,出现相互覆盖的情况

image.png

编码源于生活、代码世界其实也处处体现着生活中的朴素哲学思维。纵然并发场景存在一些可能的隐患问题,但我们也不必因噎废食,正所谓先了解它、再掌控它。

作为提升吞吐性能的不二良方,下面我们就一起来尝试按照问题解决型的思路一步步推进,换个角度探讨下多线程并发相关的内容,全面了解下多线程并发世界的各种关联,进而更从容优雅的让并发为我们所用,成为我们提升系统性能的神兵利器。

多线程——并发第一步

并发探险的第一关,就是如何支持并发。下面大概列举下常见的几种方式:

⭐️子线程⭐️

一些简单的场景中,我们为了提升主线程的处理性能,会将过程中一些耗时操作放到一个单独的子线程中进行同步处理。在代码中可以通过创建临时子线程的方式来执行即可:

代码语言:javascript复制
public void buyProduct() {
    int price = getPrice();
    // 子线程同步处理部分操作
    new Thread(this::printTicket).start();
    // 主线程继续处理其它逻辑
    doOtherOperations(price);
}

⭐️线程池⭐️

频繁创建线程、销毁线程的操作属于一种消耗性能的操作,而且创建线程的数量不可控。所以对于一些固定需要在子线程中并行处理的任务场景,我们可以通过创建线程池的方式,固定维护着一批可用线程,循环利用,去处理任务,以实现提升效率与便于管控的诉求:

代码语言:javascript复制
private ExecutorService threadPool = Executors.newFixedThreadPool();

public void testReleaseThreadLocalSafely() {
    // 任务直接放到线程池中进行处理
    threadPool.submit(this::mockServiceOperations);
}

⭐️定时器⭐️

定时器是一种比较特殊的多线程并发场景,也是经常可能会被忽视掉的一种情况。定时器也是在子线程中执行的,多个定时器之间、定时器线程与主线程之间、定时器线程与业务子线程之间都会以多线程的形式并发处理。

代码语言:javascript复制
@Scheduled(cron = "0 0/10 * * * ?")
public void syncBusinessInfo() {
    // do something here...
}

⭐️Tomcat等容器⭐️

常见的服务运行容器,比如Tomcat等,都是支持并发请求执行的。而常见的基于SpringBoot实现的服务,其service类都是由Spring进行托管单例对象。这种场景是比较常见的多线程场景。

改为多线程并发执行,虽然效率是提升了,但是问题也来了——数据执行结果不准确

结果不对,显然是我们无法接受的。所以摆在我们面前的下一难题,就是要保证执行结果数据的准确。

synchronized与lock

在JAVA中提到线程同步,使用最简单、应用频率最高的非synchronized关键字莫属了。它是 Java 内建的一种同步机制,代表了某种内在锁定的概念,当一个线程对某个共享资源加锁后,其他想要获取共享资源的线程必须进行等待,synchronized 也具有互斥排他的语义。具体用法如下:

  • synchronized修饰实例方法,相当于是对类的实例(this)进行加锁,进入同步代码前需要获得当前实例的锁
代码语言:javascript复制
public synchronized void test() {
    //...
}
  • synchronized修饰代码块,相当于是给对象(syncObject)进行加锁,在进入代码块前需要先获得对象的锁
代码语言:javascript复制
public void test() {
    synchronized(syncObject) {
        //允许访问控制的代码   
    }
    // 其它操作
}
  • synchronized修饰静态方法,相当于是对LockTest.class)进行加锁
代码语言:javascript复制
public class LockTest {
    public synchronized static void test() {
        //...
    }
}

对于被锁的目标对象而言,锁是具有排他性的,也就是同一个对象上的多个带锁方法,同一时刻只有1个线程可以抢到锁,其余都会被阻塞住。比如下面的代码,线程A和线程B分别同时请求method1method2,虽然调用的是不同的方法,但是两个线程其实是在争夺同一把锁

代码语言:javascript复制
public class LockTest {
    public synchronized void method1() {
        // ...   
    }
    public synchronized void method2() {
        // ...
    }
}

image.png

由于synchronized属于JVM关键字,属于一种比较重量级的锁。在JDK中还提供了个Lock类,提供了众多不同类型的锁,供各种不同场景诉求使用。

代码语言:javascript复制
public void test() {
    Lock lock = ...;
    lock.lock();
    try{
         // ...
    }catch(Exception ex){
         // ...
    }finally{
        // ...
        lock.unlock(); 
    }
}

synchronized不同,使用Lock的时候需要特别注意最后一定要可靠的释放掉占用的锁

到这里,再测试会发现,多线程并发执行,数据结果也对,似乎是没什么问题——但是这样真的就结束了吗?

如果并发编程仅仅就这么点内容,那显然对不上它在编码界的地位。我们接着往下看。

死锁——不期而遇的小惊吓

经过前面的内容,我们知道了使用多线程的方式来实现并发处理,也知晓了可以通过加锁的方式来保证对共享数据编辑的顺序性与准确性。而加了锁之后稍不留神间,也许就会出现死锁

image.png

一个线程A已经持有一个锁的情况下同时又去请求调用另一个加锁的对象或者代码块,而这个被请求的对象又被另一个线程B所持有,而这个线程B,又恰好在等待此时被线程A所持有的加锁资源或代码块,于是两个线程都在沉默中无限等待下去,便会出现死锁

看一个实际业务场景:

一个运维管理系统,用于维护虚拟机资源以及部署的业务进程信息,且支持按照虚拟机维度和业务进程维度进行分别查看相关信息。即:

  1. 查看虚拟机VM信息,需要一并获取到上面部署的Process信息
  2. 查看Process信息,需要一并获取其所位于的虚拟机的信息。

假定基于SpringBoot框架进行代码实现,DeployedProcessServiceVmService实例由Spring框架进行托管,为单例对象,然后彼此自动注入对方实例。假定由于业务逻辑需要,对两个服务类的执行方法进行了加锁处理。部署进程管理服务DEMO代码如下:

代码语言:javascript复制
@Service
public class DeployedProcessService {
    @Autowired
    VmService vmService;

    public synchronized void manageDeployedProcessInfo() {
        // 获取进程信息
        collectProcessInfo();
        // 获取进程所在VM信息
        vmService.manageVmInfo(this);
    }    
}
代码语言:javascript复制
@Service
public class VmService {
    @Autowired
    DeployedProcessService deployedProcessService;

    public synchronized void manageVmInfo() {
        // 获取此VM基础信息
        collectVmBasicInfo();
        // 获取此VM上已部署的进程信息
        deployedProcessService.manageDeployedProcessInfo(this);
    }
}

我们使用两个独立进程同时分别去查询VM信息以及Process信息,模拟并发操作的场景,会发现永远等不到结果。为啥呀?因为死锁了!

我们可以通过jstack命令来看下此时的JVM内线程堆栈情况,会发现有提示Found one Java-level deadlock,然后可以看到死锁的堆栈:

代码语言:javascript复制
Found one Java-level deadlock:
=============================
"ForkJoinPool.commonPool-worker-2":
  waiting to lock monitor 0x000000001cf532b8 (object 0x000000076c29bf28, a com.veezean.skills.lock.VmService),
  which is held by "ForkJoinPool.commonPool-worker-1"
"ForkJoinPool.commonPool-worker-1":
  waiting to lock monitor 0x000000001fce9f88 (object 0x000000076c29f460, a com.veezean.skills.lock.DeployedProcessService),
  which is held by "ForkJoinPool.commonPool-worker-2"

Java stack information for the threads listed above:
===================================================
"ForkJoinPool.commonPool-worker-2":
        at com.veezean.skills.lock.VmService.manageVmInfo(VmService.java:)
        - waiting to lock <0x000000076c29bf28> (a com.veezean.skills.lock.VmService)
        at com.veezean.skills.lock.DeployedProcessService.manageDeployedProcessInfo(DeployedProcessService.java:)
        - locked <0x000000076c29f460> (a com.veezean.skills.lock.DeployedProcessService)
        at com.veezean.skills.lock.Main.lambda$main$(Main.java:)
"ForkJoinPool.commonPool-worker-1":
        at com.veezean.skills.lock.DeployedProcessService.manageDeployedProcessInfo(DeployedProcessService.java:)
        - waiting to lock <0x000000076c29f460> (a com.veezean.skills.lock.DeployedProcessService)
        at com.veezean.skills.lock.VmService.manageVmInfo(VmService.java:)
        - locked <0x000000076c29bf28> (a com.veezean.skills.lock.VmService)
        at com.veezean.skills.lock.Main.lambda$main$(Main.java:)

Found  deadlock.

关于死锁的产生原因,网上或者书中给出的答案无外乎就是说如下四个原因要同时成立,就会死锁:

  1. 互斥
  2. 占有并等待
  3. 非抢占
  4. 循环等待

不知道大家看到上面这个解释是啥感觉?懂还是不懂?反正我的经历是:在我懂之后,看这4点中的每一点都很在理;而我不懂时,我依旧不知道啥原因导致的死锁。其实,用白话解释死锁的产生原因,就是两个或者多个线程各自拿到了一个锁,然后自己依赖别人的锁,别人依赖自己的锁,然后彼此都在相互等待,永远没有办法等到。

那么应该如何解决呢?

还是以上面代码为例,一个最简单的方式,就是两个Service类的加锁方法不要相互调用,各自Service类中独立实现所有逻辑即可。

小提示:

一个好的经验,就是加锁的方法嵌套调用另一个加锁的方法时,多留个心眼,看看会不会出现相互依赖或者循环依赖的情况。

锁优化思想——降低锁的影响

规避了可能存在的死锁问题之后,另一个问题又出现在我们面前——性能。我们采用多线程并发编程的初衷,是为了尽可能的提升整体的处理性能,但是加锁之后,加锁的地方反而成为了整个并发处理的一个堵点,导致整个多线程并发的效果大打折扣。

image.png

所以,如何降低锁对多线程并发处理的影响,成为飘在程序员面前的一团新的乌云。为此也衍生出了多种处理与应对策略,比如降低锁的范围以减少锁持有时间缩小锁粒度以降低夺锁竞争利用读写锁减少加锁场景等等。

降低锁范围

这个其实很好理解,因为加锁范围越大,意味着持锁执行的时间就会越久,那么其他线程阻塞等待的时间就会越久,这样整个系统的堵点就会越发明显。而如果能够将一些并不需要放到同步锁内执行的逻辑放到外部去并行执行,这样就会降低锁内逻辑的处理时长,其余线程阻塞等待时间也就会缩短。

image.png

举个例子。假如现在有个更新文章内容的需求,其处理逻辑如下:

  1. 校验当前用户是否有权更新
  2. 校验文章内容重复度
  3. 检查文章中是否有违禁词
  4. 更新到数据库中
  5. 加载到ES中

为了保证并发更新操作的准确性,对方法添加synchronized同步锁,保证多线程顺序执行:

代码语言:javascript复制
public synchronized void updateArticle() {
    verifyAuthorInfo();
    checkArticleDuplication();
    checkBlackWords();
    saveToDb();
    loadToEs();
}

但是实际分析下,其实几个操作其实只有一个环节需要做同步锁处理,其余的操作其实并不会有任何的同步问题,因此我们按照缩小锁范围的优化策略,可以将synchronized锁范围缩小:

代码语言:javascript复制
public void updateArticle() {
    verifyAuthorInfo();
    checkArticleDuplication();
    checkBlackWords();
    saveToDb();
    loadToEs();
}

private synchronized void saveToDb() {
    // ...
}

缩小锁粒度

锁的粒度越大,多线程请求的时候对锁的竞争压力越大,对性能的影响越大。如果将锁的粒度拆分小一些,这样同时请求到同一把锁的概率就会降低,这样线程间争夺锁的竞争压力就会降低。

可以看下下面的示意图,4个线程请求同一锁时,其中1个线程可以抢到锁,其余三个线程将处于等待;而将锁拆分为3个子锁的时候,这样4个线程中只有1个线程处于等待:

image.png

上面演示的就是分段锁的概念。在JAVA7之前,面试的时候经常会遇到的一个问题就是ConcurrentHashMapHashTable都是线程安全的,为啥ConcurrentHashMap的性能上会更好些呢?其实就是因为ConcurrentHashMap使用了分段锁Segment)的方式实现的:

image.png

⭐️补充一下⭐️

上面为啥要强调是JAVA7之前呢?因为JAVA7开始,ConcurrentHashMap的线程安全策略变了,改为了基于CAS的策略了

细化锁场景

对于同一个共享数据的各种操作,很多时候并不是所有多线程操作都会出数据错乱问题,一般情况下只有写操作才会改变数据的内容,而多个线程同时执行读取操作的时候并不会对数据产生影响,所以这个读取的场景其实无需和写操作使用相同的同步锁逻辑。所以为了满足此场景,出现了读写锁

image.png

读写锁的特点就是,针对读操作和写操作,提供了不同的加锁同步策略,具体而言:

  1. 读读不互斥
  2. 读写互斥
  3. 写写互斥

在 Java 中,读写锁是使用 ReentrantReadWriteLock 类来实现的,其中:

  • **ReentrantReadWriteLock.ReadLock **表示读锁,它提供了 lock 方法进行加锁、unlock 方法进行解锁。
  • **ReentrantReadWriteLock.WriteLock **表示写锁,它提供了 lock 方法进行加锁、unlock 方法进行解锁。

代码示例如下。创建读写锁,然后通过readLock和writeLock方法,可以分别获取到读锁和写锁:

代码语言:javascript复制
// 创建读写锁
final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 获得读锁
final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
// 获得写锁
final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

在读取操作的场景,直接使用读锁,使用完成后需要可靠释放锁:

代码语言:javascript复制
public String readObject() {
    // 读锁使用
    readLock.lock();
    try {
        // 业务代码...
    } finally {
        readLock.unlock();
    }
}

在写操作的场景使用写锁,使用完成后同样需要可靠释放锁:

代码语言:javascript复制
public void writeObject() {
    // 写锁使用
    writeLock.lock();
    try {
        // 业务代码...
    } finally {
        writeLock.unlock();
    }
}

其它策略

除了上述介绍的各种锁优化策略,还有很多不同类型的锁,整体思路大体相同,此处不再展开描述,具体可以参见这篇文章:《不可不说的JAVA“锁”事》:

image.png

无锁胜有锁——就是要站着还把钱挣了

为了保证多线程的数据安全,我们引入了同步锁;为了降低同步锁的影响,我们绞尽脑汁去降低锁竞争几率。但是勤劳的程序员永远不会满足眼前的结果、不然头顶也不会这么早的锃光瓦亮。于是,一个灵魂拷问又飘了出来:能不能既使用多线程并发处理、又不用加同步锁?

于是乎,一些无锁解决方案开始在某些特定的并发场景内崭露头角。

ThreadLocal空间换时间

很多时候,编码世界汇总对程序性能的优化,无外乎是时间与空间的权衡。当系统更关注服务的处理响应时长,就会使用一些缓存的策略,降低CPU的重复计算,以此来提升性能。

回到我们多线程的场景,为了保证多个线程对同一个共享内存对象的访问安全,所以通过同步锁的方式来保证串行访问,这样就会造成CPU的排队等待,性能受阻。那么,如果各个内存不去访问这个统一的共享对象,而是访问自己独享的对象,这样不就互不干扰、无需阻塞等待了吗?

比如下面图中的收费站场景,多条路最后需要经由同一个收费站,所以导致收费站这里会出现堵塞。而如果每条路都建一个自己的收费站,这样就有效避免了堵塞的状况。

image.png

image.png

仿照相同的原理,ThreadLocal便出现了。它通过冗余副本的方式,使得某个内存共享对象在各个线程上都有自己的拷贝副本。在尝试去了解ThreadLocal结构与原理前,可以先看下ThreadLocalset方法实现源码:

代码语言:javascript复制
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

翻译成白话文,先获取到当前线程信息,然后获取到当前线程对应的ThreadLocalMap对象,然后将当前对象以及要存储的内容值存到Map中。也就是说:ThreadLocal只是一个方法封装,具体的数据实际存储在ThreadLocalMap中,而这个ThreadLocalMap每个线程都有自己专属副本,里面存储着这个线程执行过程中使用的所有ThreadLocal对象以及对应数值(代码里面可能会new多个不同的ThreadLocal对象,比如有的用于存储当前用户,有的用于存储当前token信息之类的)。

image.png

从ThreadLocal的实现原理中,我们可以发现其适用的场景是有限的,即只适用于需要在单个线程内全局共享的场景,而不适用于需要在多个线程间做数据交互共享的场景

⭐️适用场景举例⭐️

一个SpringBoot构建的后端服务系统,对外以Controller方式提供诸多Restful接口方法供客户端调用。客户端调用的时候会携带token信息,然后鉴权逻辑中根据token获取到具体用户信息并缓存到内存中,后续的业务处理逻辑中有多处会需要获取该用户信息。

这是ThreadLocal使用的一个典型场景,在通过token鉴权完成后,将用户信息设置到ThreadLocal对象中,这样后续所有需要用的地方,直接从ThreadLocal中获取就行了。

为了方便后续使用,我们先封装一个工具类,提供些静态方法,便于对ThreadLocal进行操作:

代码语言:javascript复制
public class CurrentUserHolder{
    private static final ThreadLocal<UserDetail> CURRENT_USER = ThreadLocal.withInitial(() -> null);

    public static void cacheUserDetail(UserDetail userDetail) {
        CURRENT_USER.set(userDetail);
    }

    public static UserDetail getCurrentUser() {
        CURRENT_USER.get();
    }

    public static void clearCache() {
        CURRENT_USER.remove();
    }
}

在业务处理开始之前先统一设置下用户的缓存信息。因为是基于SpringBoot项目来讲解,所以我们实现一个HandlerInyerceptor的实现类,并在preHandle方法中根据token获取到用户详情并缓存到ThreadLocal中:

代码语言:javascript复制
public class AuthorityInterceptor implements HandlerInterceptor {
    private static final ThreadLocal<UserDetail> CURRENT_USER = ThreadLocal.withInitial(() -> null);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        log.info("request IN, url: {}", request.getRequestURI());
        try {
            UserDetail userDetail = userAuthService.authUser(request.getHeader("token"));
            // 校验通过,缓存用户信息
            CurrentUserHolder.cacheUserDetail(userDetail);
            return true;
        } catch (Exception e) {
            // 校验没通过,清理线程数据
            CurrentUserHolder.clearCache();
            return false;
        }
    }
}

因为鉴权通过之后,会将当前的用户信息添加到缓存中,并进入到后续的业务实际处理代码中,所以业务处理的时候如果需要获取当前登录用户信息的时候,可以直接从CurrentUserHolder中获取即可。

代码语言:javascript复制
public void collectBookToMySpace(Book book) {
    UserDetail user = CurrentUserHolder.getCurrentUser();
    // 其他逻辑省略
}

借助ThreadLocal可以让我们实现在线程内部共享对象,以此规避多线程间的同步等待处理,但是使用完毕之后,还需要保证清除掉当前线程的缓存数据值。为什么要这么做呢?拿线程池举个例子:

既然是为每个线程拷贝一份独立的副本,对于同一个线程而言拿到的数据是同一个,那么对于使用线程池来处理多任务的场景,线程都是重复利用的,这样会导致同一个线程中正在处理的任务可能会拿到上一个任务设置的共享值。对于业务处理而言可能会得到非预期结果。

当然,除了可能会导致业务处理的时候前后任务缓存数据错乱,使用完毕不清理缓存,有些时候还容易导致内存泄漏的问题。所以编码的时候、尤其涉及内存资源使用的时候,用完回收始终会是一个好习惯。

⭐️可靠清除线程副本⭐️

既然知道在使用完成之后需要可靠的清理掉当前线程的ThreadLocal副本数据,但是对于一些流程比较长、或者逻辑比较复杂的系统,其线程任务的退出分支可能有很多条,那么怎么样才能做到可靠清理、避免有分支遗漏呢?

  1. 如果是自己实现的线程池或者线程分发操作,在子线程的调用顶层位置通过try…finally…包裹调用逻辑,并在finally中进行释放操作。
代码语言:javascript复制
public void testReleaseThreadLocalSafely() {
    threadPool.submit(() -> {
        try {
            // 设置token信息
            TOKEN.set("123456");
            // 执行业务处理操作
            mockServiceOperations();
        } finally {
            // finally分支中可靠清除当前线程的ThreadLocal副本
            TOKEN.remove();
        }
    });
}
  1. 基于一些框架系统实现的场景,比如SpringBoot项目,可以定制个Interceptor并在afterCompletion退出前回调方法中,添加上对应的清理逻辑。
代码语言:javascript复制
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) {
    CurrentUserHolder.clearCurrentThreadCache();
}

volatile保证可见性

synchronized保证数据同步处理的原理不一样,volatile主要解决的是数据在多线程之间的可见性问题,但是不保证数据操作的原子性。volatile用于修饰变量,可以保证每个共享此变量数据的线程都可以第一时间拿到此值的真实值。

image.png

当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值

但是因为它不保证原子操作,所以如果有多个线程同时来修改变量的值时,还是可能会出现问题。所以,volatile适合那种单个线程去修改值内容,但是多个线程会共享读取变量结果的场景。

image.png

比如项目代码中,我们需要支持系统配置属性的动态变更,我们可以将系统参数使用volatile修饰,然后使用固定一个线程进行系统属性值的维护,其余业务线程负责从内存中读取即可。

发布订阅模式

在并发编程中使用发布订阅模式能够解决绝大多数并发问题。

多线程协同配置的场景下,可以借助MQ实现发布订阅模式,可以保证每个任务都分配给不同的消费者进行处理,这样就不会出现重复处理的问题、也减少了线程或者进程间资源争夺的风险,正可谓是“无锁胜有锁、四两拨千斤”的典型。

image.png

对于MQ的选型,如果是单进程内多线程间的使用,可以使用BlockingQueue来实现,而用于分布式系统内时,可以选用一些消息队列中间件,比如RabbitMQKakfa等。

CAS乐观锁策略

所谓CAS,也即Compare And Swap,也即在对数据执行写操作前,先比较下数据是否有变更,没有变更的情况下才去执行写操作,否则重新读取最新记录并重新执行计算后,再执行比对操作,直到数据写入完成。CAS是一种典型的乐观锁策略,其与常规的加同步锁的处理策略有很大的不同,属于一种比较经典的无锁机制

image.png

并发场景对公共存储(比如MySQL)中的数据进行更新的时候,经常会需要考虑并发更新某个记录的情况,尤其是一些界面编辑更新的场景更是常见。这个场景下使用CAS机制可以有效解决问题。

先看个问题场景:

有个需求任务跟踪管理系统,团队内的成员可以编辑团队内的待办需求事项的进展描述,如果团队内有两个人都打开了某一个需求页面进行编辑进展说明,那么第一个人改动完成存储的内容,会被第二个人保存改动时直接覆盖掉。

image.png

使用CAS的思路来解决上面场景提及的更新覆盖问题,我们可以对DB中的记录数据增加一个version字段,更新的时候必须保证version字段值与自己最初拿到的version值一致时才能更新成功,同时在每次update的时候更新下version字段,这样问题就解决啦,看下过程:

image.png

代码实现起来也很简单:

代码语言:javascript复制
public void updateItem(Item  item) {
    int updateResult = updateContentByIdAndVersion(item.getContent(), item.getId(), item.getVersion());
    if (updateResult == ) {
        // 没有更新成功任何记录,说明version比对失败已经有别人更新了
        // 要么放弃处理,要么重试
    }
}

CAS始终按照无锁的策略进行数据的处理、处理失败则重试或放弃。在竞争不是很激烈的并发场景下,可以有效的提升整体的处理效率,因为大部分的场景下都会执行成功,只有在少量的请求出现并发冲突的时候,才会进入自旋重试。但当竞争很激烈的场景下,会导致写入操作高频率失败进入自旋,这要会大大的浪费CPU资源,且因为自旋其实就是线程不停的循环,所以大量自旋可能会使得CPU的占用比较高。

image.png

补充说明

在单进程内的多线程间使用CAS机制保证并发的时候,需要结合volatile一起使用,以此来保证原子性与可见性。

另外,我们在前面有提到JAVA7之前ConcurrentHashMap使用的是分段锁的技术,而从JAVA7之后ConcurrentHashMap线程安全保护的实现逻辑是改为了CAS synchronized的方式来实现,以此来获取更好的性表现。

分布式锁——跨越进程的相逢

前面介绍了单进程内的一些多线程高并发场景的应对方案。但高并发的场景,除了单线程内的多线程间的并发之外,还有分布式系统集群内的多个进程之间的并发。所以分布式锁应运而生。

举个例子:

数据库有一张“热议话题”表,表中每条记录有个“当前热度”字段,热度计算服务需要每隔5分钟执行一次计算,然后更新表中每条记录的热度字段。

为了保证系统的高可用,热度计算服务部署了多个进程节点,由定时器触发,每隔5分钟计算一次。

image.png

分布式锁的实现,有多种方式,比较常见的是基于Redis或者MySQL来实现。分布式锁在实现以及使用的时候,需要关注几个要点;

  • 客户端请求锁的整体操作需要是个原子操作,即需要保证锁分配结果的唯一性
  • 客户端获取到锁之后进行自身业务逻辑处理,处理完成之后必须要主动释放锁需要注意判断下是否是自己所持有的锁
  • 锁要有兜底退出机制,防止某个客户端获取到锁之后出现宕机等异常情况,导致锁被持有后无法释放,其它客户端也无法继续申请

image.png

比如基于Redis实现分布式锁的时候,使用示意如下:

代码语言:javascript复制
// 获取锁
public boolean accuireLock(String lockName) {
    return stringRedisTemplate.opsForValue().setIfAbsent(lockName, "", 1L, TimeUnit.MINUTES);
}

// 释放锁
public void releaseLock(String lockName) {
    stringRedisTemplate.delete(lockName);
}

上面代码中,简单的使用setNx命令来实现分布式锁的申请,又设置了redis超时时间,一旦在设定的时间内依旧没有主动释放锁,则redis将主动释放锁,供其余客户端再来请求。

在上面归纳的分布式锁实现与使用的注意要点中,在提及业务处理完成之后要主动释放锁的时候,有特别补充了一个要求:需要判断下是否是自己的锁,只能释放自己的锁!为什么一定要强调这一点呢?以上述代码为例,看一种可能的情况:

image.png

从上图可以看出,Client-1申请到了锁1,但是Client-1执行超时导致锁1被强制释放掉了,而Client-2随后获取到了锁2并开始执行处理逻辑。此时Client-1的任务终于执行完成了,然后去释放了锁(Client-1自己不知道自己超时,还是按照正常逻辑去释放锁),结果Client-3此时又申请到了锁3,然后开始执行自己的任务。这个时候就会出现了Client-2Client-3同时执行的异常情况了。

整个问题出现的原因就是释放锁的时候没有校验是否是自己的锁,所以出现了越权释放了别人的锁的情况。为了避免此情况的发生,我们对前面的分布式锁实现使用逻辑稍加改动即可:

首先是申请分布式锁的时候,可以生成个随机UUID作为锁的value值,如果申请成功,则直接返回此锁的UUID唯一标识:

代码语言:javascript复制
/**
 * 获取锁,如果获取成功,则返回锁的value值(UUID随机)
 */
public String accuireLock(String lockName) {
    String uuid = UUID.randomUUID().toString();
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, uuid, 1L,
            TimeUnit.MINUTES);
    if (result == null || !result) {
        throw new RuntimeException("获取锁失败");
    }
    return uuid;
}

锁释放的时候,需要同时提供锁名称与锁的唯一UUID标识值,先根据锁名称尝试获取下已存在的锁,然后比对下锁value值是否一致,如果一致,则表名当前的锁是自己锁持有的这把锁,然后将其释放即可:

代码语言:javascript复制
/**
 * 释放锁,先比对锁value一致,才会释放
 */
public void releaseLock(String lockName, String lockUuid) {
    String lockValue = stringRedisTemplate.opsForValue().get(lockName);
    if (!StringUtils.equals(lockValue, lockUuid)) {
        throw new RuntimeException("锁释放失败,锁不存在");
    }
    stringRedisTemplate.delete(lockName);
}

当然啦,我们这里举例是使用的Redis的setNx命令来实现的,此实现可以轻松的应对大部分的使用场景。但是,上述的释放锁实现代码中可以看出,由于获取锁内容、比对锁内容、释放锁内容三个操作是独立分开的,存在无法保证操作原子性的弊端。如果项目的要求级别较高,可以考虑使用LUA脚本封装为原子命令操作来解决,或者使用redis官方提供的redission来实现。

补充:并发与并行

本文主要讨论了多线程并发编程相关的内容,提到并发,往往还有个容易混淆的概念,叫并行。关于并行的具体介绍与实现策略,以及并发与并行的详细区别,可以参见我的另一个文档《JAVA基于CompletableFuture的流水线并行处理深度实践,满满干货》,此处不述。

综合而言:

  1. 如果业务处理逻辑是CPU密集型的操作,优先使用基于线程池实现并发处理方案(可以避免线程间切换导致的系统性能浪费)。
  2. 如果业务处理逻辑中存在较多需要阻塞等待的耗时场景、且相互之间没有依赖,比如本地IO操作、网络IO请求等等,这种情况优先选择使用并行处理策略(可以避免宝贵的线程资源被阻塞等待)。

总结

好啦,关于多线程并发场景常见问题的相关应对策略,这里就探讨到这里啦。那么看到这里,相信您应该有所收获吧?那么你是否有实际应对过多线程并发场景的开发呢?那你是如何处理的呢?是否有发现过什么问题呢?

此外

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:https://github.com/veezean/JavaBasicSkills

我是悟道,聊技术、又不仅仅聊技术~

期待与你一起探讨,一起成长为更好的自己。

0 人点赞