java并发编程之一、概述
一、为什么有并发
其实回答为什么要有并发,目的在于在合适的地方使用并发,只有在合适的地方使用并发,才会最大化的发挥并发的优势。
- 协调计算机不同设备间的速率。现代计算机由CPU、内存、硬盘、网络这几部分组成,其中CPU中还有寄存器。其中,CPU是计算发生的地方,寄存器、内存、硬盘和网络是数据存储的地方。发生计算的时候,需要将数据从硬盘或者网络加载到内存和寄存器,在CPU中进行实际的计算。而CPU的计算速率最高,寄存器、内存、硬盘和网络的存取速率依次递减,并且有着数量级的差异,比如CPU从寄存器中加载数据的速度是从内存中加载数据的速度的千百倍,而从内存中加载数据的速度是从硬盘中加载速度是成千上万倍。为了充分发挥CPU的功效,比如一个任务执行过程中需要从硬盘中加载数据,那就可以暂时将CPU的使用权让渡给另外一个任务,始终保持CPU的高速运转。所以,如果一个任务不是纯计算类型,比如加载了数据之后,就始终使用这批数据进行计算,而是在计算,从硬盘或者从网络加载数据再参与计算,那么就有必要通过并发编程,让耗时的加载操作单独运行在IO设备上,让渡出CPU的使用。
- 可分割的子任务的并发执行加速任务整体的执行。比如在一个很大的序列中进行查找操作,则可以分段的并发进行查找;在比如再向yarn上提交任务的时候,需要对用户的jar包进行验证,也需要请求yarn判断用户申请的资源是否可用,那么这两部分也可以并发进行;类似的任务,都可以通过开启并发执行来加速整体任务的速度。
- 充分利用现代cpu的多核能力。假如有一个纯计算类型的任务,而且计算的序列也不是特别大,但是为了充分提高系统的tps,也有必要通过并发执行,对于一个24核的CPU,如果系统中单线程计算,假如计算耗时10ms,那么1秒钟可以支持100个请求,如果采用20个并发线程,那么就可以支撑2000个并发。
二、并发编程的三个任务
上面列举了使用并发执行的一些场景,纯粹是从业务的角度进行的描述,如果从技术的角度看,怎么理解并发执行,也可以从下面三个大的方面来看:
- 分工,分工转换到java的编程领域就是指的如何创建相应的执行单元——线程,比如最简单的使用Thread,Runnable,Callable等,在比如使用Future,FutureTask,CompletetableFuture以及CompletionService,再加上绕不开的ThreadPool和ForkJoin,所有的这些组成了java并发执行的执行单元模型,在现实编程过程中,需要根据实际的需要选择相应的任务抽象去使用。
- 协调,有一些任务只需要分工,比如碎纸机,将废纸分发到不同的碎纸机就可以了,每个执行相互之间无依赖无关联,但有一些任务之间可能是存在关联的,比如做土豆泥,洗土豆和削土豆可以并行执行,但是需要等这一批土豆都洗干净削好批之后统一上蒸锅,这样就存在不同子任务的等待关系,再比如喝茶,需要洗茶杯,洗茶叶,烧开水,泡茶,前面几步可以并行,但是泡茶这步就需要等前面的都执行完。对应的java的wait和notify/notifyall机制,join机制,CountDownLatch和cyclicBarrier等,采用各种合适的机制来协调各个子任务之间的脚步。
- 互斥,不管是分工还是协调,都没有涉及并发执行的一个关键问题,即对共享变量的存取,比如一个并发的累加器,每次子任务执行的时候需要获取最新的值,执行完毕需要将值写回去,比如java中的对象,在多个并发实例中共享的时候,就需要考虑多个并发实例对对象的属性即状态的使用,这些都是线程的互斥,或者也可以叫线程安全。java中也提供了很多线程安全的保证,有基于语言级别的,比如synchronize,有基于api级别的,比如lock,比如ReentrantLock或者ReadWriteLock等等。这些部分是并发执行中最容易出现问题的地方,需要仔细选择,多加小心。
三、并发编程bug的根本原因
上面我们说到,如果涉及到分工和协调,只要仔细选择相应的执行模型,一般而言问题不大,但是一旦涉及到互斥,则往往会有一些诡异的问题发生,这些问题的发生的根本原因有很多,但如果从三个方面加以注意,则可以大大的减少类似问题的产生,所以,最好在问题发生之前消灭问题,一旦发生问题,这三个方面的思考,往往也有益于解决问题。
- 原子性,原子性指的是一个或多个操作在CPU的执行的不可中断,举个反例,比如很多高级语言都有的i++这个操作,从高级语言的层面来看,这的确是一条语句,但从CPU的角度看,这里就包含三个操作,操作1,加载i到寄存器,操作2,在CPU上执行加1操作,操作3,将结果i写会到内存,而CPU的切换会发生在任何一个操作之后,所以很多高级语言都需要有自己的机制来保证某个操作的原子性,即保证从高级语言层面的某个操作无论在CPU层面面临多少个操作都不会被切换。
- 可见性,可见性指的是多个并行实例对同一个共享变量的正确存读,这个很好理解,比如并行实例1对共享变量的存写能够被并行实例2正确的读取,不会发生脏读等情况,由于每个CPU都有自己的寄存器,多个并行实例在多个CPU上执行的时候,对于内存中共享变量的读取,如果不加任何约束的话,往往就会读取到错误的数据。
- 有序性,有序性指的是由于编译器对代码进行优化重拍之后,导致的可能出现的操作执行顺序的问题。比如Student student = new Student("LiHua","Grade 6"),这个高级语言语句,可能认为的执行顺序是:1.分配一块内存空间,2.初始化内存空间,3.返回内存空间地址给student,而经过指令重排之后,可能的执行顺序却是:1.分配一块内存空间,2.返回内存空间的地址给student,3.初始化这块内存空间。如果是单并发实例执行,都没有问题,但如果是多并发实例执行,在一个并发实例中产生一个student,另一个并发实例在获取这个共享变量的时候,发现已经非null了,直接使用,但实际是还没有初始化,调用自然会报错。
对于上面这三个方面的问题,各个高级语言都有自己的解决方案,java是如何解决这三个方面的问题了?
- 解决“原子性”问题,上面说到出现原子性问题的原因是并发实例对某个共享变量的使用的,所以如果可以限制在同一时刻只有一个并发实例可以存取内存中的共享变量,那么就可以解决原子性问题,在java中就是利用锁的机制来实现这种访问控制。
- 解决“可见性”和“有序性”问题,可见性问题是由于寄存器作为内存缓存在CPU中引起的,有序性问题是由于编译器进行指令重排引起的,如果只是简单的禁止缓存和指令重排,则大大影响代码的执行的性能,在java中,这两个问题都通过JMM中的一系列机制来保证,其中最关键的就是happens-before原则,即“前一个操作的结果对后一个结果可见”,这里的前后,当然指的是CPU时钟的绝对时间。happens-before原则有如下六项:
- 程序的顺序性原则。同一个并发实例中,先执行的代码的结果对后执行的代码是可见的,当然这看起来像废话,但的确是首要的基本原则。
- volatile原则,volatile变量的写操作对于volatile的读操作具有可见性,哪怕是在多个并发实例中,如果一个变量被volatile修饰,一旦这个变量被写入,之前的其他并发实例读取的变量都会被缓存失效,进而重新从内存中加载。
- 传递性原则,意即如果A happens before B,B happens before C,那么可以推论出A happens before C。这个原则比较抽象,但却很有用。比如 在一个线程中执行x=1,y=2,在另一个线程中读取y和x的值,其中y是volatile变量,由于在线程1中根据顺序性原则,x happens before y,根据volatile原则,线程1中的y写 happens before 线程2 中的y读,所以x happens before y读,所以在线程2中也可以看到x变成2。
- 加解锁的原则,加锁 happens before 解锁,意即对于同步代码块,前一个线程是否锁之后,对共享变量的修改,对后面获得锁的线程可见。
- 线程start原则,假如在线程1中启动了线程2,那么在调用线程2的start方法之前线程1所做的任何对状态的修改,对线程2都是可见的。
- 线程join原则,假如在线程1中调用了线程2的join方法,那么在join方法返回后,线程2中任何对状态的修改都对线程1可见。
通过以上这些原则,编译器就能够更智能和更有效的对代码进行优化,判断和决定何时可以指令重排,何时不可以,何时可以使用缓存,何时不可以。另外,final关键字也是解决可见性和有序性的一个方案,毕竟final的共享变量不能被修改,那么就可以随便用,不存在读到错误的值。但要注意final的基本类型和final的引用类型的区别,final的基本类型是完全的不可修改,但是final的引用类型,引用本身自然不可以修改,但是引用的对象的状态,如果不是final状态,则可以修改,所以如果要使用final的引用类型,就要注意这里,如果不能让引用类型的所有状态都变成final,则要注意使用时是否合适。