Java多线程问题常用的几种场景(不是全部)通常需要包括如下几个方面:
- 共享资源的互斥访问(比如:资源初始化过程)。
- 有限资源的访问控制(比如:数据库连接池会限制只有有限个线程可以同时保持链接)。
- 多线程之间访问的通讯配合(比如:典型的生产-消费模式场景)
- 构建线程池
- Callable & Future
- 读过写少的并发控制(比如:资源初始化过程)。
针对这些比较典型的多线程使用场景,Java已经在他的工具包中提供了很多有力的工具协助开发人员进行处理。下面会针对这几种典型的场景列举一下比较常用的对应解决方案。当然,许多并发控制工具并非只能解决固定的场景,这里仅列出个人认为比较典型的应用。
1、共享资源的互斥访问
最简单也是最古老的方式是随便创建一个对象(任何类型的实例都可以)作为lock,通过
synchronized块进行某个关键代码段的互斥访问需求。比如如下的伪代码:
synchronized (lockObject) {
// here is your code.
}
这里,如果有第二个线程想要进入这个synchronized块,那没有任何商量的余地,就是必须获得lockObject这把锁。
So,这种方法的特点是——简单而粗暴。当然你可以更简单,比如直接在方法的签名上加上synchronized关键字,那么就相当于使用this作为锁对象的大synchronized块而已。代码使用上貌似更简单。
JDK1.5之后,除了上面这种Java关键字的加锁方式之外,新引入了Lock框架。这样就提供了编程API级别的锁支持,比较常用的写法像下面这样:
public class LockDemoClass{
private final Lock lock = new ReentrantLock();
public void lockOnMethod(){
//some codes which does not need synchronization
lock.lock();
try{
//some codes which need synchronization
}finally{
lock.unlock();
}
}
}
这个方式明显看上去比之前的synchronized块繁杂了一些,但却在许多方面提供了更大的灵活性。
关于两者的常见对比大概有如下几个方面:
- 在性能上,在JDK1.5或者之前的时期,确实有人诟病Java原生的synchronized关键字锁的太重,甚至有人认为弃用synchronized而投降Lock是因为性能更好。这种假设在JDK1.6之后,由于JVM内部对synchronized的优化之后,这种考虑的因素几乎可以忽略不计了。因为synchronized有了大幅度的性能提升。
- 在灵活度上,Lock明显高于前者,尽管有些灵活性未必被开发人员经常使用。比如:
- Lock可以在不同方法中分别加锁解锁;
- 如果你需要,Lock可以在保证等待线程进入互斥代码块的排队顺序(当然这要付出一些性能的代价);
- 你可以通过设置timeout来控制获取锁时尝试等待的时间,而不是想前者那样无限的等待下去(这时加大死锁可能性的一个重要的因素)。
- 在线程的通讯机制上,前者使用锁对象上wait/notify/notifyAll(继承自Object)来进行线程间的等待唤醒通讯;后者引入了Condition机制。一个Lock上可以创建多个Condition实例,具体condition的语义由开发人员把控,而线程之间的通讯由Condition的await/signal/signalAll来完成,这三个方法的语义基本上和上面的Object三个方法对应。
2、有限资源的访问控制
这个是
Semaphore的典型应用场景。
典型的代码结构如下:
public class SemaphoreDemoClass{
//here 5 can be replaced to any int value
private final Semaphore semaphore = new Semaphore(5);
public void accessControlMethod(){
//some codes which does not need multi-thread access control
semaphore.acquire();
try{
//some codes which need multi-thread access control
}finally{
semaphore.release();
}
}
}
Semaphore本质上很像一个带计数性质的阀门。每次访问这个阀门上的
acquire()方法时,Semaphore都会将自身的计数器自减1,当Semaphore本身计数器已经被自减到0的时候,再去访问这个Semaphore上的acquire()方法的线程就会被Block住,于是这种机制就顺利的保证了统一资源的同时访问只能在有限个数目的线程范围内。
而且,从这个机制中可以看出,对于内部计数器最大值为1的Semaphore,就可以是另外一种资源互斥访问的形式了。
3、多线程之间访问的通讯配合
通常情况下,我们认为较优的多线程使用场景是:多线程访问的资源是可以切分的,每个线程操控的资源和其他线程是不相干的。这种场景最爽,每个线程不需要鸟其他线程,只要自己单干就好。
但现实很残酷,绝大部分的多线程使用场景都是需要“团队合作”的。有团队合作,就需要有沟通。
关于线程间通讯沟通机制,已经在前面的共享资源的互斥访问中做了一些介绍。这里再补充一些细节场景:
- 如果是生产-消费模式,可以借助JDK1.5之后BlockingQueue机制去做(具体选用的BlockingQueue的实现类根据具体情况选择)
- 多个线程需要步调一致行动,必须保证同一时间点一起执行,比如模仿高并发时的模拟;多个线程必须保证等待其他线程都完成任务之后才可以进入下一步操作(当然两个线程之间的协调等待也可以通过join()来实现)。这两种典型的场景就可以使用CountDownLatch来完成。CountDownLatch内部和Semaphore实现机制相同,都会维护一个计数器,但不同的是,前者只有计数器为0时才允许线程开始执行。
- 两个线程之间构建的生产-消费模型,但采用“互不干涉”的模式进行交互。注意:这里和一般的生产-消费模式一个最大的区别是,他不是即时生产即时消费的,而是双方分别进行自己的生产和消费(通常会使用两个资源,比如两个队列分别进行生产和消费),其中任何一方ready之后,就可以利用Exchanger.exchange(resourceObject)来完成生产资源和消费资源互换。
4、构建线程池
通过
Executors的相应的静态方法可以获得具体的
ExecutorService的实例(通常为
ThreadPoolExecutor),通过这个具体的线程池的
submit方法,可以提交执行自己业务线程。这里线程池内部按照什么机制安排被提交的线程,主要取决于构建
ThreadPoolExecutor时,所使用的构造函数的参数,比如不同的内部
BlockingQueue。
5、Callable & Future
传统的Thread都是Runnable风格,没有返回值。如果你想得到一个线程执行的结果,只能通过join等方法,Block在那里,等待线程执行结束。
JDK1.5之后的有一个新特性就是引入了
Callable和
Future接口。这里最长用的使用方式就是结合上面第4点提到的线程池的submit方法获得Futurn实例。这样,就不需要阻塞业务当前主线程的执行,在将来的某个时刻在通过Future的get方法获得执行结果。
Future本身的引入,更大的意义是在多线程的环境中引入异步处理的机制,这在某些场景下实现真正的并发非常有意义。
6、读过写少的并发控制
这种比较典型的场景是资源的初始化过程中,某个资源需要初始化一次。只要初始化这一次之后,后面所有的访问全部是读取。
比如某个内存的cache,他会有初始化一堆内容进去。在真正暴露他对外服务之前,我们是需要完成所有资源的cache的,否则可能会造成cache的内容不全而导致的问题。
这里,根据
ReadWriteLock的特点,可以将cache初始化的过程用
writeLock包住,将资源的读取用
readLock包住。这样,除了在writeLock尚未释放之前所有的其他尝试获取readLock的线程需要被Block住之外,其他大多数读取的场景下,多个线程可以共享readLock,可以获得无阻塞的高性能。