目录
1. 并发编程面临的挑战
并发编程的目的:为了让程序运行的更快
1.1 上下文切换
时间片:CPU分配给各个线程的时间(几十毫秒)
CPU通过时间片分配算法来循环执行任务,当上一个任务执行一个时间片后会切换到下一个任务。在进行上下文切换之前,会保存上一个任务的状态,以便下次切换回这个任务时,可以在加载这个任务的状态,任务从保存到再加载的过程就是一次上下文切换。
与串行执行相比,多线程不一定会快,因为线程有创建和上下文切换的开销
1.1.1 减少上下文切换
1)无锁并发编程:多线程竞争锁时,会引起上下文切换,可以避免使用锁,例如:不同线程处理不同段的数据
2)CAS算法:CAS(CompareAndSwap),比较并替换。CAS需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生变化了则不交换。整个比较并替换的操作是一个原子操作。CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
3)使用最少线程:避免创建不需要的线程
4)使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。协程不是被操作系统内核所管理,而完全是由程序所控制。Java的原生语法中并没有实现协程(某些开源框架实现了协程,但是很少被使用)
1.2 死锁
避免死锁的方法
1)避免一个线程同时获取多个锁
2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
3)使用定时锁(lock.tryLock(timeout))
4)数据库锁的加锁和解锁必须在一个数据库连接里
1.3 资源限制
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件或软件资源。
解决:硬件(使用集群并行执行程序);软件(使用资源池将资源复用)
小结:本章主要说了并发编程会遇到的三个问题,并一一阐述了概念以及解决的方法
2. Java并发机制的底层实现原理
Java代码运行原理:Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化成汇编指令在CPU上执行,所以并发机制依赖于JVM的实现和CPU指令。
2.1 volatile
是轻量化的synchronized,保证了共享变量的可见性(当一个线程修改这个变量时,其他线程能读到修改的值)并且不会引起线程上下文切换和调度。
2.1.1 volatile实现原理
volatile变量在进行写操作时转化成汇编语言会多出一行lock前缀的指令,这个指令会引起两个步骤
1)将当前CPU 缓存行(缓存的最小操作单位)的数据写回系统内存
为了提高处理速度,CPU不直接和内存通信,而是先将系统内存的数据读到内部缓存后再进行操作,操作完后也不知道什么时候写回内存。但现在,JVM会向CPU发送一条lock前缀的指令,将这个变量所在的缓存行写回到系统内存中。
2)这个写回内存的操作会使其他CPU里缓存了当前内存地址的数据无效
为了保证各个处理器的缓存是一致的,就是实现缓存一致性协议,每个处理器通过嗅探总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读到处理器缓存里。
2.2 synchronized实现原理
Java中的每一个对象都可以作为锁。具体表现形式:对于普通同步方法,锁是当前实例对象;对于静态同步方法,锁是当前类的Class对象;对于同步代码块,锁是synchronized括号里配置的对象。
当一个线程想要访问同步代码时,必须先得到锁,退出或抛出异常时必须释放锁。
2.2.1 Java对象头
synchronized的锁是存在Java对象头里的,Java对象头里的Mark Word里默认存书对象的HashCode、分代年龄和锁标记位,如下图。存储的数据会随锁标志位的变化而变化。
2.2.2 锁的升级
Java SE1.6中,锁的状态有:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。锁能升级不能降级的策略,目的是为了提高获得锁和释放锁的效率。
1. 偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低引入了偏向锁。
1.1 获取锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单的测试一下对象头的Mark Work里是否存储着指向当前线程的偏向锁。
1.2 释放锁
使用等到竞争出现才释放锁的机制。上述流程CAS替换Mark Work失败时,会撤销偏向锁,流程如下:
1.3 关闭锁
可以使用JVM参数 -XX:-UseBiasedLocking=false关闭偏向锁,那么程序默认进入轻量级锁。
2. 轻量级锁
2.1 获取锁
JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(displaced Mark Work),并将对象头的Mark Work复制到锁记录中,然后线程尝试使用CAS将对象头的Mark Work替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
2.2 释放锁
使用CAS将displaced Mark Work替换回对象头。如果成功,则表示没有发生竞争。如果失败,表示当前锁存在竞争,锁会膨胀为重量级锁。
2.2.3 锁的优缺点 ![](https://i-blog.csdnimg.cn/blog_migrate/4b4091ad232a1f5eedb85c5c60f45c8b.png)
2.3 原子操作的原理
原子操作:不可被中断的一个或一系列操作
2.3.1 处理器实现原子操作
首先处理器会自动保证基本的内存操作(读取或写入一个字节)的原子性,但复杂的内存操作不能保证,例如跨多个缓存行的访问等,所以处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
1. 总线锁
场景:i++,共享变量被多个处理器同时进行操作,多个处理器同时从各自的缓存中读取变量,操作完后,分别写入系统内存中(如图),导致两次i++最终结果为2。
使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
缺点:把cpu和内存之间的通信锁住了,这导致其他处理器不能操作其他内存地址的数据,开销比较大。
2. 缓存锁
频繁使用的内存会缓存在处理器的L1、L2、L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行。内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器会修改内部的内部地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会组织同时修改由两个以上处理器缓存的内存区域数据。
2.3.2 Java实现原子操作
Java通过锁和循环CAS的方式来实现原子操作
1. 自旋CAS
JVM中的CAS操作是利用了处理器提供的CMPXCHG指令实现的,基本思路就是循环进行CAS操作直到成功为止。
CAS实现原子操作的三大问题
1)ABA问题:A -> B -> A,CAS检查时会发现值没有发生变化,实质上是变化了。解决思路:使用版本号,1A -> 2B -> 3A
2)循环时间长开销大:长时间不成功,会给CPU带来非常大的开销
3)只能保证一个共享变量的原子操作,把多个共享变量合并成一个共享变量来操作。
2. 锁
锁机制保证了只有获得锁的线程才能操作锁定的内存区域。
本章小结:本章研究了volatile、synchronized和原子操作的实现原理。