前言
目前CPU的运算速度已经达到了百亿次每秒,所以为了提高生产率和高效地完成任务,基本上都采用多线程和并发的运作方式。
并发(Concurrency):是指在某个时间段内,多任务交替处理的能力。CPU把可执行时间均匀地分成若干份,每个进程执行一段时间后,记录当前的工作状态,
释放相关的执行资源并进入等待状态,让其他线程抢占CPU资源。
并行(Parallelism):是指同时处理多任务的能力
在并发环境下,由于程序的封闭性被打破,出现了一下特点:
1、并发程序之间有相互制约的关系。直接制约体现在一个程序需要另一个程序的计算结果;间接体现为多个程序竞争共享资源,如处理器、缓冲区等。
2、并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点
3、当并发数设置合理并且CPU拥有足够的处理能力时,并发会提高程序的运行效率。
线程安全
线程是CPU调度和分派的基本单位,为了更充分地利用CPU资源,一般都会使用多线程进行处理。多线程的作用是提高任务的平均执行速度,但是会导致程序可理解性变差,
编程难度加大。
同样,线程数不是越多越好,合适的线程数才能让CPU资源被充分利用。
线程安全的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作
,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与同进程内的其他线程共享该进程的所有资源。
线程在生命周期内存在多种状态。
有NEW(新建状态)、RUNNABLE(就绪状态)、RUNNING(运行状态)、BLOCKED(阻塞)状态、DEAD(终止状态)五种状态。
1、NEW,即新建状态,是线程被创建且未启动的状态。创建线程的方式有三种,第一种是继承自Thread类,第二种是实现Runnable接口。第三种是实现Callable接口。
推荐使用实现Runnable接口的方式,因为继承Thread类往往不符合里氏替换原则(任何父类出现的地方都可以用子类替换,子类不要重写重载父类的方法)。
Callable与Runnable有两点不同:
1):Callable可以通过call()获得返回值。
2):call()方法可以抛出异常。而Runnable只有通过setDefaultUncaughtExceptionHandler()的方式才能在主线程中捕捉到子线程异常。
2、RUNNABLE,即就绪状态,是调用start()方法后运行之前的状态。需要注意的是线程的start()不能被多次调用,否则会抛出IllegalStateException异常
3、RUNNING,即运行状态,是run()正在执行时线程的状态。线程可能会由于某些因素而退出RUNNING,如时间、异常、锁、调度等
4、BLOCKED,即阻塞状态,进入此状态,有以下几种情况
同步阻塞:锁被其他线程占用
异步阻塞:调用Thread的某些方法,主动让出CPU执行权,比如sleep()、join()等
等待阻塞:执行了await()
5、DEAD,即终止状态,是run()方法执行结束,或因异常退出后的状态,此状态不可逆转。
jstack看到的线程状态:
状态名称 | 说明 |
NEW | 初始状态,线程被创建,但还没有调用其start()方法 |
RUNNABLE | 运行状态,Java线程将操作系统中的就绪与运行两种状态笼统地称作“运行中” |
BLOCKED | 阻塞状态,表示线程阻塞于锁 |
WAITING |
等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断) |
TIME_WAITING | 超时等待状态,该状态不同于WAITING,它在达到超时时间后,将会返回到运行状态 |
TERMINATED | 终止状态,表示该线程已经执行完毕 |
线程安全的核心理念就是“要么只读,要么加锁”
线程安全问题只有在多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从以下维度考量:
1、数据单线程内可见:单线程总是安全的。通过限制数据只在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立的
虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal就是采用这种方式来实现线程安全的。
2、只读对象:只读对象总是线程安全的。它的特性是允许复制、拒绝写入。最典型的只读对象有String,Integer等。一个对象想要拒绝任何写入,必须满足以下条件:
1):使用final关键字修饰类。避免被继承,如String,调用其的方法不会影响其原来的值,只会返回一个新构造的字符串对象
2):使用private final 关键字避免属性被中途修改
3):没有任何更新方法
4):返回值不能可变对象为引用
3、线程安全类:某些线程安全类内部有非常明确的线程安全机制。比如StringBuffer就是一个线程安全类,其内部采用sychronized关键字来修饰相关方法
4、同步与锁机制:如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。
合理利用好JDK提供的并发包(java.util.concurrent),并发包主要分为以下几个类族:
1):线程同步类,这些类使得线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用Object类的wait和notify进行同步的方式,主要代表为
CountDownLatch、Semaphore、CycleBarrier等
2):并发集合类,如ConcurrentHashMap,它不断优化,从刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有BlockingQueue、CopyOnWriteArrayList等
3):线程管理类,如使用Executors静态工厂或者使用ThreadPoolExecutor来创建线程池等,另外,通过ScheduledExecutorService来执行定时任务
4):锁相关类。锁以Lock为核心,最有名的是ReentrantLock。
线程安全的实现方法
1、互斥同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量
和信号量都是主要的互斥方式。
synchronized:
在Java语言中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和moniterexit这两个字节码指令,
这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,
那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者Class对象来作为锁对象。
在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定(monitor为0),或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行
monitorexit指令时会将计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。Java中的线程
是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态之中,因此状态转换需要耗费很多的处理器时间。
对于简单的同步代码块,状态转换的操作有可能比用户代码执行的时间还要长。所以synchronized是Java语言中的一个重量级的操作。同时虚拟机本身也做了一些优化,譬如在通
知操作系统阻塞线程之前加入一段自旋等待的过程,避免频繁地切入到核心态中。
Lock:
相比synchronized,ReentrantLock增加了一些高级功能,只要有以下3项:等待可中断、可实现公平锁、以及锁可以绑定多个条件。
等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他的事情,可中断特性对处理执行时间非常长的同步块很有帮助。
公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,
如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无需这么做,只需要多次调用newCondition()方法即可。
2、非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认
为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被
阻塞的线程需要唤醒等操作。
随着硬件指令集的发展,我们可以选择:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据
有争用,产生了冲突,那就再采用其他的补偿措施(常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步
操作称为非阻塞同步。
为什么使用乐观并发策略需要”硬件指令集的发展“才能进行呢?因为我们需要操作和冲突检测这两个步骤具备原子性,靠什么来保证呢?如果这里使用互斥同步来保证就
失去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
1)、测试并设置(Test-and-Set)
2)、获取并增加(Fetch-and-Increment)
3)、交换(Swap)
4)、比较并交换(Compare-and-Swap,CAS)
5)、加载链接/条件存储(Load_Linked/Store-Conditional,LL/SC)
其中后面的两条是现代处理器新增的。
CAS指令需要3个操作数,分别是内存位置(在Java中可以理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V
符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,且上面的处理过程是一个原子操作。
不过CAS有个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期
间它的值曾经被改成了B,后来又被改为A,那CAS操作就会误认为它从来没有改变过。这个漏洞称为CAS操作的ABA问题。java.unit.concurrent包为了解决这个问题,提供了一个
带有标记的原子引用类”AtomicStampReference“,它可以通过控制变量值的版本来保证CAS的正确性。不过这个类目前来说比较鸡肋,大部分情况下ABA问题不会影响程序并发
的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
3、无同步方案
要保证线程安全,并不是一定要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同
步措施去保证正确性,因此会有一些代码天生就是线程安全的,比如:
可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制
权返回后,原来的程序不会出现任何错误。...
线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,
我们就可以把共享数据的可见范围控制在同一个线程之内,这样,无需同步也能保证线程之间不出现数据争用的问题。如ThreadLocal类可以实现线程本地存储的功能。每个线程
的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V键值对,ThreadLocal对象就是当前
线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
什么是锁?
单机单线程时代,没有锁的概念。自动出现了资源竞争,人们才意识到需要对部分执行现场进行加锁,表明自己短暂拥有。计算机中的锁也从最开始的悲观锁,发展到
后来的乐观锁、偏向锁、分段锁等。锁主要提供了两种特性:互斥性和不可见性。
1、用并发包中的锁类
Lock是顶层接口,它的实现逻辑并未用到synchronized,而是利用了volatile的可见性。ReentrantLock对了Lock接口的实现主要依赖了Sync,而Sync继承了
AbstractQueuedSynchronizer(AQS),在AQS中,定义了一个volatile int state 变量作为共享资源。如果线程获取此共享资源失败,则进入同步FIFO队列中等待;
如果成功获取资源就执行临界区代码。执行完释放资源时,会通知同步队列中的等待线程来获取资源后出对并执行。
ReentrantLock的lock()方法默认执行的是NonfairSync中的lock()实现,利用Unsafe类的CAS;期望state值为0时将其值设为1,返回是否成功
因此ReentrantLock的lock()方法只有在state为0时才能获得锁,并将state设为1。这样其他线程就无法获取锁,只能等待。
由于ReentrantLock是可重入锁,即在获得锁的情况下,可以再次获得锁。并且线程可以进入任何一个它已经拥有的锁所同步着的代码块。若在没有释放锁的情况下,
再次获得锁,则state加1,在释放资源时,state减1,因此Lock获取多少次锁就要释放多少次锁,直到state为0。
2、利用同步代码块
同步代码块一般使用Java的sychronized关键字来实现,有两种方式对方法进行加锁操作:
1):第一,在方法签名处加synchronized关键字
2):第二,使用synchronized(对象或类)进行同步
这里的原则是锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类,能锁代码块,就不要锁方法。
synchronized锁特性由JVM负责实现。在JDK的不断优化迭代中,synchronized锁的性能得到极大提升,特别是偏向锁的实现,使得synchronized已经不是昔日那个
低性能且笨重的锁了。
JVM底层是通过监视锁来实现synchronized同步的。监视锁即monitor,是每个对象与生俱来的一个隐藏字段。使用synchronized时,JVM会根据synchronized的当前
使用环境,找到对应的monitor,再根据monitor的状态进行加、解锁的判断(使用monitorenter和monitorexit指令实现)。例如:线程在进入同步方法或者代码块时,会获取
该方法或代码块所属对象的monitor(在Java对象头中),进行加锁判断。如果成功加锁就成为该moniter的唯一持有者。monitor在被释放前,不能被其他线程获取。
从字节码看synchronized锁的具体实现:
同步方法的方法元信息中会使用ACC_SYNCHRONIZED标识该方法是一个同步方法。同步代码块中会使用monitorenter及monitorexit两个字节码指令获取和释放monitor。
如果使用monitorenter进入时monitor为0,表示该线程可以持有monitor后续代码,并将monitor加1;如果当前线程已经持有了monitor,那么monitor继续加1(可重入);
如果monitor非0,其他线程就会进入阻塞状态(和Lock的state类似)。
JVM对synchronized的优化主要在于对monitor的加锁、解锁上。JDK6后不断优化使得synchronized提供三种锁的实现,包括偏向锁、轻量级锁、重量级锁,还提供自动的
升级和降级机制。JVM就是利用CAS在对象头上设置线程ID,表示这个对象偏向于当前线程,这就是偏向锁。
偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。在锁的对象头中有一个ThreadId字段,当第一个线程访问锁时,如果该锁没有被其他线程访问
过,即ThreadId字段为空,那么JVM让其持有偏向锁,并将ThreadId字段设置为该线程的ID。当下一次获取锁时,会判断当前线程的ID是否与锁对象的ThreadId一致,如果一致
,那么该线程不会再重复获取锁,从而提高了程序的运行效率。如果出现锁的竞争情况,那么偏向锁会被撤销并升级为轻量级锁。如果资源的竞争非常激烈,会升级为重量级锁。
偏向锁可以降低竞争开销,它不是互斥锁,不存在线程竞争情况,省去了再次判断的步骤,提升了性能。
线程同步:
计算机的线程同步,就是线程之间按某种机制协调先后次序执行,即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存进行操作,一直等待直到该线程完成操作,其他线程才能对该内存进行操作。
在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。所谓原子性,是指不可分割的一系列操作指令,在执行完毕前不能被任何其他操作中断,那么全部执行,要么全部不执行。
如果每个线程对共享变量的修改都是原子操作,就不存在线程同步问题。
i++操作就不具备原子性,它需要分成三部ILOAD-->IINC-->ISTORE。
CAS(Compare And Swap)操作具备原子性
实现线程同步的方式有很多,比如同步方法、锁、阻塞队列等。
Volatile
happen-before:先从happen-before了解线程操作的内存可见性。把happen before定义为方法hb(a,b)表示a happen before b。如果hb(a,b)且hb(b,c),那么能够推导出hb(a,c)。
即如果a在b之前发生,那么a对内存的操作b是可见的,b之后的操作c也是可见的。
指令优化:计算机并不会根据代码顺序按部就班地执行相关指令。CPU处理信息时会进行指令优化,分析哪些取数据可以合并进行,哪些存数据动作可以合并进行。CPU拜访
一次遥远的内存,一定会到处看看,是否可以存取合并,以提高执行效率。
happen-before是时钟顺序的先后,并不能保证线程交互的可见性。那什么是可见性呢?可见性是指某线程修改共享变量的指令对其他线程来说都是可见的,它反应的
是指令执行的实时透明度。先从Java内存模型说起:每个线程都有独占的内存区域,如操作栈,本地变量表等。线程本地内存保存了引用变量在堆内存中的副本。线程对
变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存(主内存)中去。在这个操作过程中,该线程对副本的操作,对于其他线程都是不可见的。
volatile的英文本义是挥发、不稳定的,延伸意义为敏感的。当使用volatile修饰变量时,意味着任何对此变量的操作都会在主内存中进行,不会产生副本,以保证共享
变量的可见性,局部阻止了指令重排的发生。它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景,
使用volatile修饰变量则非常合适。volatile一写多读最典型的应用是CopyOnWriteArrayList,它在修改数据时会把整个集合的数据全部复制出来,对写操作加锁,修改完成
后,再用setArray()把array指向新的集合。使用volatile可以使线程尽快地感知array的修改,不进行指令重排,操作后即对其他线程可见。
源码如下:
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { /** The array, accessed only via getArray/setArray. */ 真正存储元素的数组 private transient volatile Object[] array;
final void setArray(Object[] a) { array = a; } }
在实际的业务中,如果不确定共享变量是否会被多个线程并发写,保险的做法是使用同步代码块来实现线程同步。
另外,因为所有的操作都需要同步给内存变量,所以volatile一定会使线程的执行速度变量,故要慎重定义和使用volatile属性。
信号量同步
信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。基于时间维度的CountDownLatch和基于信号维度的Semaphore。
CountDownLatch:
CountDownLatch允许一个或多个线程等待其他线程完成操作
public class CountDownLatch { /** * Synchronization control For CountDownLatch. * Uses AQS state to represent count. */ private static final class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = 4982264981922014374L; Sync(int count) { setState(count); } int getCount() { return getState(); } protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; } protected boolean tryReleaseShared(int releases) { // Decrement count; signal when transition to zero for (;;) { int c = getState(); if (c == 0) return false; int nextc = c-1; if (compareAndSetState(c, nextc)) return nextc == 0; } } } private final Sync sync; ... }
可以看到其和ReentrantLock类似,都是依赖AQS中的可见性变量state。
CountDownLatch:倒数计数器,它的内部提供了一个计数器,再构造闭锁时必须指定计数器的初始值(state),且计数器的初始值必须大于0。另外它还提供了一个countDown方法来操作计数器的值,(在子线程中)每调用一次countDown方法计数器会减1,直到计数器的值减为0(类似于获取到了锁),所有因调用await方法而阻塞的线程都会被唤醒。
适用于比如日常开发中经常会遇到需要在主线程中开启多线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景
如一个百万量级的任务量交给线程池去执行,避免一次将全部任务丢给线程池,导致线程池没有空闲线程任务被拒绝;按批次执行任务,每批次执行几千或一万,执行完当前批次再执行下一批次:
for (int i = 1; i <= pageCount; i++) { PageInfo pageInfo = new PageInfo((i-1)*pageSize, pageSize); // 查询到一页然后进行处理 List<Info> infos = infoDao.queryInfoByPage(infoVo, pageInfo); // 同步执行这一页 // 当前页的大小 int size = infos.size(); final CountDownLatch countDownLatch = new CountDownLatch(size); for (final Info info : infos) { // 交给线程池去执行 EXECUTORS.execute(new MDCRunnable(new Runnable() { @Override public void run() { try { // 执行业务逻辑 ... } catch (Exception e) { LOGGER.error( "续费检查异常" + e.getMessage(), e); } finally { // 无论执行结果如何都要countDown,避免影响后续的续费检查 countDownLatch.countDown(); } } })); } // 等待线程池执行完一页的自动续费检查 try { countDownLatch.await(); } catch (InterruptedException e) { LOGGER.error("await一页续费检查异常" + e.getMessage(), e); } }
Semaphore:
CountDownLatch是基于计数的同步类。在实际编码中,可能需要处理基于空闲信号的同步情况。
控制并发线程数的Semaphore,是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。源码:
public class Semaphore implements java.io.Serializable { private static final long serialVersionUID = -3222578661600680210L; /** All mechanics via AbstractQueuedSynchronizer subclass */ private final Sync sync; /** * Synchronization implementation for semaphore. Uses AQS state * to represent permits. Subclassed into fair and nonfair * versions. */ abstract static class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = 1192457210091910933L; Sync(int permits) { setState(permits); } final int getPermits() { return getState(); } ... }
// 默认使用非公平锁 static final class NonfairSync extends Sync { private static final long serialVersionUID = -2694183684443567898L; NonfairSync(int permits) { super(permits); } protected int tryAcquireShared(int acquires) { return nonfairTryAcquireShared(acquires); } }
// 构造方法 public Semaphore(int permits) { sync = new NonfairSync(permits); } ... }
使用Semaphore的构造方法指定同时处理的线程的数量,只有在调用Semaphore的acquire()成功后,才可以往下执行,完成后执行release()释放持有的信号量,下一个线程就可以马上获取这个
空闲信号量进入执行。
Semaphore的release()和CountDownLatch的countDown方法相同。
acquire()方法在直到有一个信号量空闲时,才会执行后续的代码,否则,将一直阻塞。可以理解为Semaphore允许有创建对象时在构造中指定的锁的数量,当锁有空闲时,线程就可以拿到
锁,否则将一直等待。拿到锁的线程执行完毕后释放锁。
countDown和release都是使state减1。
Semaphore可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接,只允许10个线程并发执行操作数据库
用途设想:
固定100个线程执行百万级的任务,每个线程执行完就去任务池中去取一条任务执行。使用例子(实测任务非常多时性能不高):
// 设定30个信号量,可以看成30个服务窗口 final Semaphore semaphore=new Semaphore(30); for (final Info info : infos) { new MDCRunnable(new Runnable() { @Override public void run() { try { // acquire拿到信号量后才会往下执行 semaphore.acquire(); ... LOGGER.info("done"); } catch (Exception e) { LOGGER.error("执行异常" + e.getMessage(), e); } finally { // 无论执行结果如何都要释放持有的信号量 semaphore.release(); } } }).run(); }