谈谈我对Java并发的理解——读《Java并发编程实战有感》

谈谈我对Java并发的理解——读《Java并发编程实战有感》

线程安全

先要谈一下最根本的问题,线程安全问题。可以说多线程编程带来的影响有利有弊,好处自然是提高处理器的利用率,加快任务执行速度,弊端是线程安全问题。我在阅读这本书之前已经学了一段时间的JavaWeb开发,学的时候对线程安全不太敏感,主要原因可以总结为两点:
- 很少显式使用多线程
- 框架的屏蔽

众所周知,不论是SpringMVC还是Struts框架,它们都是使用多线程的方式,对每一个请求都会创建一个线程去处理,所以我们平时不容易去主动地接触多线程,更不要说线程安全了。读这本书的时候当我发现书中的加锁等机制用得非常频繁的时候,我的内心是非常震惊的,因为我平时开发很少用到锁,尤其是对象的setter和getter都会加锁时内心简直崩溃。阅读完这本书之后,对线程安全的理解更深了一些,现总结如下:

  • 线程安全主要是多条具有关联的、对同一变量进行修改的语句不能以原子性执行的情况下,会出现线程安全问题。注意一条语句并不能保证其原子性(++i不是原子执行的),多条语句更不会有原子性。在多线程环境下,每一条语句都有可能被中断。
  • 如果整个Application不存在任何成员变量(包括实例成员变量和静态成员变量,以下提到的成员变量都包括这两种;如果没有指明是不可变的,那么默认为可变的),或者所有成员变量都是final的(包括成员对象的属性),那么一定是线程安全的。
  • 如果存在可变的成员变量,不论是基础数据类型还是引用类型,那么都是需要关注它的线程安全问题;如果存在并发写的情况,那么修改它的时候需要加锁。部
  • 局部变量一般情况下访问是不需要加锁的,因为它是栈封闭的,多线程不会并发访问到。但是如果局部变量是参数,并且它来自于成员变量,那么也是需要关心其线程安全问题的,甚至setter和getter在某些情况下也要加锁。
  • 成员变量是容器类的情况下,不仅需要关注容器的线程安全问题(通常可以使用一些线程安全的容器类),还需要关注容器中元素的线程安全问题(通常是实体类)。
  • 局部变量如果是基础数据类型,而非引用类型,那么一定线程安全的,因为传参是使用拷贝的方式,修改它不会影响成员变量。
    重点! 从数据库中取出的数据不是成员变量,而是局部变量,修改它不需要考虑线程安全问题。。对于并发修改数据库的问题,应该交由数据库来处理。由数据库来处理并发读写问题,这往往与数据库的隔离级别有关。
    谈谈我对Java并发的理解——读《Java并发编程实战有感》_第1张图片
    以上这四种隔离级别都是拒绝并发修改的,比如MySQL的InnoDB引擎,会在修改表的时候加表锁或行锁,此时其他修改被锁数据的请求是会被阻塞的,直到锁被释放才会执行。对于并发读写,这四种隔离级别的要求各不相同,前三种是允许并发读写的,即当有Session在修改表的时候,仍有Session可以读取数据,第四种串行化是拒绝并发读写的,无论是读还是写,都必须要以串行的方式执行(当然是一种非常低效的方式,很少使用,并发度非常低,但最大限度地保证了数据一致性)。
  • 如果成员变量不存在并发修改(多线程同时写),但存在并发读写(多线程同时读写),那么如果要求读到的结果是最新的,那么也要对成员变量的内存可见性有要求,这个问题会在下面继续讨论。
  • 如果我们编程时很少使用成员变量、主要数据都是从数据库从取出的,那么可以较少地关注并发修改产生的问题。但是如果是读-改-写回这种执行序列,当要求这几个操作是原子的话,即从数据库读到这个数据之后,在写回之前不允许其他事务写,仅依赖于数据库隔离级别是不可行的。前三种数据库隔离级别都无法避免丢失修改(两个事务交替修改同一数据,造成前一个事务的修改被覆盖掉),要解决这个问题有两种方式:
    • 在代码中使用锁,比如synchronized或者ReentrantLock
    • 在SQL中使用
      • 乐观锁:使用多版本并发控制,增加一列version,在写之前再读取一次,如果version相同,说明在第一次读之后其他事务没有写过,那么可保证读-改-写回是原子的。
      • 悲观锁:比如select … for update,在读的时候就加上互斥锁,其他事务无法读写该数据,本事务写后释放互斥锁。
  • 哪怕是有一个成员变量,不论是基础数据类型,还是引用类型,在存在并发修改的情况下,修改它的时候都需要加锁。尤其是对多条代码的原子性有要求时,非常经典的是“读取-修改-写回”这种指令序列时,如果后续的修改依赖于之前的读取结果时,那么这个指令序列必须不可被中断(加锁)。
  • 综上所述,就成员变量的访问而言,假如存在并发修改,就需要使用Java中的锁。就数据库的读-改-写回序列而言,假如有原子执行的需求,就需要使用Java中的锁或数据库的乐观锁或悲观锁。

内存可见性

内存可见性主要是并发读写的情况下读线程要求读到的数据必须是刚被修改的数据,此时需要使用volatile关键字或原子变量或者加锁来保证这一点。加锁是一种较重的行为,而volatile关键字和原子变量是一种较为轻量的并发手段。

当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或其他对处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
而原子变量是使用无锁并行机制的,主要是CAS算法(compare-and-swap)。如果用一句话来解释CAS的话,就是:读的时候记录结果,写的时候检查是不是还是刚才读到的,如果是,那么说明读和写之间没有其他线程修改它的值,这段代码是原子执行的,可以进行修改操作;如果不是,那么说明其他线程修改了它的值,这段代码并没有原子执行,此时需要使用循环,重新读取,再检查,直至保证原子执行。

这种方式和锁有一些类似,都可以保证代码的原子执行,但是使用锁会涉及到一些线程的挂起和上下文切换问题,需要消耗资源,但是CAS仅是轮询,不涉及JVM级别。书中提到低度和中度竞争的情况下,CAS的代价是低于锁的,在高度竞争的情况下,CAS的代价是高于锁的(毕竟轮询也需要消耗资源,占用CPU),但高度竞争这种情况是比较少的。在一些细粒度的并发操作上,推荐还是使用CAS。

并发工具

  1. 同步容器类:如果将容器类型作为成员变量,那么容器必须是同步容器类。对List和Set而言,有Collections.synchronizedXXX对非同步容器类进行包装,也有CopyOnWriteXXX,CopyOnWrite适用于读多写少的情况,如果大量修改,会出现大量的内存拷贝行为,效率较低。对于Map而言,有非常高效的ConcurrentHashMap,比Collections.synchronizedMap包装的Map性能更好,主要是因为使用了CAS无锁并行机制。
  2. 阻塞队列:并发的经典模式生产者——消费者模式的一种比较好的解决方案是使用阻塞队列,在Java中是ArrayBlockingQueue和LinkedBlockingQueue。使用阻塞队列而非原生的wait-nofity或者是显式锁的await-signal,会大大降低生产者——消费者模式的开发难度。阻塞队列的原理就是生产者线程(1或多)将原料放入阻塞队列,如果阻塞队列已满,那么put方法会被阻塞,直到阻塞队列不满;消费者线程(1或多)从阻塞队列中取出原料,进行消费,如果阻塞队列为空,那么take方法会被阻塞,直到阻塞队列不空。
    另外,推荐使用有界的阻塞队列,避免生产者与消费者速度不匹配时不会无限扩展队列长度,造成OOM(OutOfMemeory异常)。
  3. 还有一些其他的工具类,比如CountDownLatch闭锁、Semaphore信号量、Barrier栅栏等,这些可能没有之前的工具使用地那么频繁,这里不再过多介绍,很多书中都有介绍。

任务执行

无限制创建线程的不足:

1. 线程生命周期的开销非常高
2. 资源消耗
3. 稳定性

解决方法:线程池 Executor框架

一般来说不推荐使用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();
    }
}

锁与CAS对比

锁的缺点:
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

谢谢大家!

你可能感兴趣的:(后端)