先要谈一下最根本的问题,线程安全问题。可以说多线程编程带来的影响有利有弊,好处自然是提高处理器的利用率,加快任务执行速度,弊端是线程安全问题。我在阅读这本书之前已经学了一段时间的JavaWeb开发,学的时候对线程安全不太敏感,主要原因可以总结为两点:
- 很少显式使用多线程
- 框架的屏蔽
众所周知,不论是SpringMVC还是Struts框架,它们都是使用多线程的方式,对每一个请求都会创建一个线程去处理,所以我们平时不容易去主动地接触多线程,更不要说线程安全了。读这本书的时候当我发现书中的加锁等机制用得非常频繁的时候,我的内心是非常震惊的,因为我平时开发很少用到锁,尤其是对象的setter和getter都会加锁时内心简直崩溃。阅读完这本书之后,对线程安全的理解更深了一些,现总结如下:
内存可见性主要是并发读写的情况下读线程要求读到的数据必须是刚被修改的数据,此时需要使用volatile关键字或原子变量或者加锁来保证这一点。加锁是一种较重的行为,而volatile关键字和原子变量是一种较为轻量的并发手段。
当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或其他对处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
而原子变量是使用无锁并行机制的,主要是CAS算法(compare-and-swap)。如果用一句话来解释CAS的话,就是:读的时候记录结果,写的时候检查是不是还是刚才读到的,如果是,那么说明读和写之间没有其他线程修改它的值,这段代码是原子执行的,可以进行修改操作;如果不是,那么说明其他线程修改了它的值,这段代码并没有原子执行,此时需要使用循环,重新读取,再检查,直至保证原子执行。
这种方式和锁有一些类似,都可以保证代码的原子执行,但是使用锁会涉及到一些线程的挂起和上下文切换问题,需要消耗资源,但是CAS仅是轮询,不涉及JVM级别。书中提到低度和中度竞争的情况下,CAS的代价是低于锁的,在高度竞争的情况下,CAS的代价是高于锁的(毕竟轮询也需要消耗资源,占用CPU),但高度竞争这种情况是比较少的。在一些细粒度的并发操作上,推荐还是使用CAS。
1. 线程生命周期的开销非常高
2. 资源消耗
3. 稳定性
一般来说不推荐使用Executors工具类创建的那些线程池,通用性较差,推荐自己new一个ThreadPoolExecutor。注意创建时的一些参数需要特别关注,尤其是阻塞队列,一定要使用有界队列,理由同上。使用有界队列需要考虑的一个问题是当队列满了的时候如何处理加入的任务。
当有界队列被填满后,饱和策略开始发挥作用。ThreadPoolExecutor的饱和策略可以通过setRejectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler的实现,每种实现都包含有不同的饱和策略:#AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy。
中止策略是默认的饱和策略,该策略将抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码,当新提交的任务无法保存到队列中执行时,抛弃策略会悄悄抛弃该任务。抛弃最旧的策略则会抛弃下一个将被执行的任务,然后尝试重新提交下一个将被执行的任务(如果工作队列是一个优先级队列,那么抛弃最旧的将抛弃优先级最高的任务)
调用者运行策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退给调用者,从而降低新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。为什么好?因为当服务器过载时,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。
最通用的中断线程的方式是使用interrupt
使用boolean变量决定线程何时停止的方式不是很好,因为任务可能永远不会检查取消标志,因此永远不会结束。
interrupt方法能中断目标线程,而isInterrupted方法能返回目标线程的中断状态。静态方法interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
当线程在非阻塞状态下中断,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得有黏性——如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。
调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
另外线程应该由其所有者中断,所有者可以将线程的中断策略信息封装某个合适的取消机制种,例如关闭方法。
由于每个线程拥有各自的中断策略,因此除非你知道中断对该线程的含义,否则就不应该中断这个线程。
这段代码是我认为取消线程最好的方式。
public class PrimeProducer extends Thread {
private final BlockingQueue queue;
public PrimeProducer(BlockingQueue queue) {
this.queue = queue;
}
public void run(){
BigInteger i = BigInteger.ONE;
try {
while(!Thread.currentThread().isInterrupted()){
queue.put(i = i.nextProbablePrime());
}
} catch (InterruptedException e) {
}
}
public void cancel(){
Thread.currentThread().interrupt();
}
}
这种方式可以解决在不存在阻塞的代码段的线程中止问题。如果存在阻塞的代码段,那么通常是先关闭阻塞的资源(比如套接字Socket),再中断线程。
下面这段代码是使用了NIO的服务器程序的监听线程,当关闭服务器时,会调用这个线程的shutdown方法,这个方法会关闭seletor,让线程从检查seletor的阻塞方法中退出,然后再中断该线程,此时可以正确地关闭该线程。
private class ListenerThread extends Thread {
@Override
public void interrupt() {
try {
try {
selector.close();
} catch (IOException e) {
e.printStackTrace();
}
} finally {
super.interrupt();
}
}
@Override
public void run() {
try {
//如果有一个及以上的客户端的数据准备就绪
while (!Thread.currentThread().isInterrupted()) {
//当注册的事件到达时,方法返回;否则,该方法会一直阻塞
selector.select();
//获取当前选择器中所有注册的监听事件
for (Iterator it = selector.selectedKeys().iterator(); it.hasNext(); ) {
SelectionKey key = it.next();
//删除已选的key,以防重复处理
it.remove();
//如果"接收"事件已就绪
if (key.isAcceptable()) {
//交由接收事件的处理器处理
handleAcceptRequest();
} else if (key.isReadable()) {
//如果"读取"事件已就绪
//取消可读触发标记,本次处理完后才打开读取事件标记
key.interestOps(key.interestOps() & (~SelectionKey.OP_READ));
//交由读取事件的处理器处理
readPool.execute(new ReadEventHandler(key));
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public void shutdown() {
Thread.currentThread().interrupt();
}
}
锁的缺点:
1. 在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。
2. volatile变量同样存在一些局限:虽然它们提供了相似的可见性保证,但不能用于构建原子的负责操作。
3. 当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行,那么所有需要这个锁的线程都无法执行下去。
4. 总之,锁定方式对于细粒度的操作(比如递增计数器)来说仍然是一种高开销的机制。在管理线程之间的竞争应该有一种粒度更细的技术,比如CAS。
非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。在基于锁的算法中,如果一个线程在休眠或自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。非阻塞算法常见应用是原子变量类(JDK1.8的ConcurrentHashMap也使用了CAS)。
即使原子变量没有用于非阻塞算法的开发,它们也可以用作一个更好的volatile类型变量。原子变量提供了与volatile类型变量相同的内存语义,此外还支持原子的更新操作,从而使它们更加适用于实现计数器、序列发生器和统计数据收集等,同时还能比基于锁的方法提供更高的可伸缩性。
这篇文章主要是聊了一下自己对并发的一些看法,并不专注于介绍并发的具体知识点,可能部分观点也有点皮面,希望各位多加指教。
最后说一句:《Java并发编程实战》绝对是Java并发的Bible,推荐所有学习Java的人去阅读这本书。我读完感觉只掌握了其中的一半,一年以后我会重新读这本书,希望能掌握其更多的精妙之处!
PS:我在读完这本书后动手写了一个使用了Java的多线程(线程池、阻塞队列、原子变量、内置锁等)和NIO的CS架构的聊天室程序(当然还有一些奇奇怪怪的功能),之后打算再写一篇博客来介绍这个程序,现在暂时把Github地址放上来,欢迎各位star和fork,如果发现代码有问题也望给予指教。除了代码之外还放上了我学习Java多线程的笔记,也一并分享给大家。
https://github.com/songxinjianqwe/Chat
谢谢大家!