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

线程安全

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

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

  • 线程安全主要是多条具有关联的、对同一变量进行修改的语句不能以原子性执行的情况下,会出现线程安全问题。注意一条语句并不能保证其原子性(++i不是原子执行的),多条语句更不会有原子性。在多线程环境下,每一条语句都有可能被中断。
  • 如果整个Application不存在任何成员变量(包括实例成员变量和静态成员变量,以下提到的成员变量都包括这两种;如果没有指明是不可变的,那么默认为可变的),或者所有成员变量都是final的(包括成员对象的属性),那么一定是线程安全的。
  • 如果存在可变的成员变量,不论是基础数据类型还是引用类型,那么都是需要关注它的线程安全问题;如果存在并发写的情况,那么修改它的时候需要加锁。部
  • 局部变量一般情况下访问是不需要加锁的,因为它是栈封闭的,多线程不会并发访问到。但是如果局部变量是参数,并且它来自于成员变量,那么也是需要关心其线程安全问题的,甚至setter和getter在某些情况下也要加锁。
  • 成员变量是容器类的情况下,不仅需要关注容器的线程安全问题(通常可以使用一些线程安全的容器类),还需要关注容器中元素的线程安全问题(通常是实体类)。
  • 局部变量如果是基础数据类型,而非引用类型,那么一定线程安全的,因为传参是使用拷贝的方式,修改它不会影响成员变量。
    重点! 从数据库中取出的数据不是成员变量,而是局部变量,修改它不需要考虑线程安全问题。。对于并发修改数据库的问题,应该交由数据库来处理。由数据库来处理并发读写问题,这往往与数据库的隔离级别有关。

    以上这四种隔离级别都是拒绝并发修改的,比如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<BigInteger> queue;public PrimeProducer(BlockingQueue<BigInteger> 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 {@Overridepublic void interrupt() {try {try {selector.close();} catch (IOException e) {e.printStackTrace();}} finally {super.interrupt();}}@Overridepublic void run() {try {//如果有一个及以上的客户端的数据准备就绪while (!Thread.currentThread().isInterrupted()) {//当注册的事件到达时,方法返回;否则,该方法会一直阻塞  selector.select();//获取当前选择器中所有注册的监听事件for (Iterator<SelectionKey> 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

谢谢大家!

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

  1. 第二季:7.怎么查看服务器默认的垃圾收集器是那个?生产上如何配置垃圾收集器的?谈谈你对垃圾收集器的理解?【Java面试题】

    第二季:7.怎么查看服务器默认的垃圾收集器是那个?生产上如何配置垃圾收集器的?谈谈你对垃圾收集器的理解?[Java面试题] 前言 推荐 7.怎么查看服务器默认的垃圾收集器是那个?生产上如何配置垃圾收集 ...

  2. Java物联网、人工智能和区块链编程实战

    我们现在生活在数字革命时代,许多新兴的数字技术正以惊人的速度发展,比如物联网. 人工智能.区块链等.这些数字技术都将越来越深入地渗透到我们生活的各个方面,并从根本 上改变我们的生活方式.工作方式和社交 ...

  3. 安卓 java内存碎片_理解Android Java垃圾回收机制

    Jvm(Java虚拟机)内存模型 从Jvm内存模型中入手对于理解GC会有很大的帮助,不过这里只需要了解一个大概,说多了反而混淆视线. Jvm(Java虚拟机)主要管理两种类型内存:堆和非堆. 堆是运行 ...

  4. java lru_LRU的理解与Java实现

    简介 LRU(Least Recently Used)直译为"最近最少使用".其实很多老外发明的词直译过来对于我们来说并不是特别好理解,甚至有些词并不在国人的思维模式之内,比如快速 ...

  5. 杨晓峰-java核心技术36讲(学习笔记)- 第1讲 | 谈谈你对Java平台的理解?

    杨晓峰-java核心技术36讲(学习笔记) 接下来我会分享杨晓峰-java核心技术36讲的学习笔记,内容较多,补充了其中一些牛人评论,相对详细(仅供个人学习记录整理,希望大家支持正版:https:// ...

  6. Java 面试经典题解析:谈谈你对 Java 平台的理解?

    作者|杨晓峰出处|极客时间<Java技术核心 36讲>专栏 从你接触 Java开发到现在,你对 Java最直观的印象是什么呢?是它宣传的 "Compile once, run a ...

  7. [java]谈谈你对Java平台的理解

    Java特性: 面向对象(封装,继承,多态) 平台无关性(JVM运行.class文件) 语言(泛型,Lambda) 类库(集合,并发,网络,IO/NIO) JRE(Java运行环境,JVM,类库) J ...

  8. 读Java核心技术36讲有感——谈谈对Java的理解,谈谈Exception和Error

    读过杨晓峰老师的36讲之后,想总结下自己的感想,写下来也有助于记忆,方便以后面试查阅和复习.题目所提到的话题本来是两讲,但是由于感想篇幅较短,所以合成一篇来写. 一.谈谈对Java平台的理解: 1.J ...

  9. 《深入理解Java虚拟机》笔记6——高效并发

    第五部分 高效并发 第十二章 Java内存模型与线程 并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类"压榨"计算机运算能力的最有力 ...

  10. 谈谈你对java的理解,java是“解释执行”这句话对吗?

    谈谈你对java的理解,java是"解释执行"这句话对吗? 先科普下什么是"解释执行"? 什么是"编译执行"? • 解释执行 解释执行时高级 ...

最新文章

  1. css让image不改变大小_变压器怎样改变电压的?
  2. c++画多边形_水彩画,这么美!怎么画出来的?
  3. HTML5中常用的标签(及标签的属性和作用)
  4. 职业生涯的8种德---非常重要
  5. jvm性能调优实战 -57数据日志分析系统的OOM问题排查
  6. 10你当前无权访问该文件夹_「文件保密小技巧」教你创建一个别人打不开也无法删除的文件夹...
  7. LAMP之Apache
  8. zigbee板子:lcd显示汉字
  9. 音频放大电路_低音升压功率放大器电子电路的完整设计
  10. 苹果再下一盘很大的棋?Metal优化作用及影响浅析
  11. LInux之gz文件压缩/解压缩
  12. mysql死锁检测算法_MySQL InnoDB如何应付死锁
  13. php显示有关html函数,php中与html标签相关的函数有哪些
  14. java实现微信订阅消息(服务通知)
  15. 访问控制(相关概述)
  16. Java 1072 开学寄语
  17. 微信公众平台测试号推送思路
  18. 3. 搞定收工,PropertyEditor就到这
  19. strstr函数.c
  20. java改变数据库配置文件信息_JAVA应用修改数据库链接信息一般在哪个配置文件中?...

热门文章

  1. 激活golang编辑器
  2. Fiddler抓包快速入门-windows网页抓包
  3. 注意力稀缺的时代,写作软件如何选择?
  4. [Devcpp]为Devc自定义编译器及Devcpp路径读取的Bug
  5. c语言图书管理信息系统源代码,C语言 图书信息管理系统 最终源代码
  6. c语言函数调用求阶乘和
  7. mysql sql常用语句大全
  8. java数据结构——抽象数据类型
  9. [N1盒子] Phicomm-N1 斐讯 N1 NAS 打造指南
  10. 从入门到高级Java书籍推荐