多线程技术可以使程序的响应更加的快,可以在进行其他工作的时候一直处于工作状态。性能得到提升,但是多线程会给程序带来多线性并发安全问题。并发的安全问题发生的原因是多个线程对同一个资源的操作而造成的不安全问题。
首先需要了解JMM(内存模型),JMM是指java内存模型,和JVM不同,它是不存在的,是一个规范模型,是一种约定。
在JMM的规定中,所有的变量都存放在主内存中,当线程调用主内存中的变量时,会拷贝一份数据到该线程的独享的工作内存中,来在此线程中来对该变量副本进行操作,操作完成后会将修改后的变量重新写会到主内存中。
主内存与工作内存八种操作指令:
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作;
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中;
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现;
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存;
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中;
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作;
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁;
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值;
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量;
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作);
关于JMM的一些同步约定:
1.线程解锁前,必须把共享变量立刻刷新到主内存中。
2.线程加锁前,必须读取主内存中的最新值到工作内存中。
3.加锁和解锁是同一把锁。
并发编程问题的核心问题---可见性,原子性,有序性
可见性:当一个线程对共享的主内存变量进行修改后,其他的线程能否立马发现,并及时更新自己的缓存的值。因为可见性的存在,所以主内存中的数据容易和线程工作内存中的数据不一致。
原子性:线程A在执行任务时,不能被打扰,也不能被分割。要么同时成功,要么同时失效。
有序性:有序性指的是程序按照代码的先后顺序执行。为了优化性能,有时候会改变程序中语句的先后顺序。 cpu 的读等待同时指令执行是 cpu 乱序执行的根源。 读指令的同时可以同时执行不影响的其他指令。
并发问题总结:
缓存导致的可见性问题,编译优化带来的有序性问题,线程切换带来的原子性问题 。其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的, 都是提高程序安全性和性能。但是技术在解决一个问题的同时,必然会带来另外 一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及 如何规避这些问题。
volatile 关键字
1.保证可见性
2.原子性无法保证
3.禁止进行指令重排
volatile 底层实现原理
使用 Memory Barrier ( 内存屏障 ) 。内存屏障是一条指令,该指令可以对编译器(软件)和处理器(硬件)的指 令重排做出一定的限制,比如,一条内存屏障指令可以禁止编译器和处理器将其 后面的指令移到内存屏障指令之前。Volatile 变量编译为汇编指令会多出#Lock 前缀.
有序性实现: 主要通过对 volatile 修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性的。
可见性实现: 主要是通过 Lock 前缀指令 + MESI 缓存一致性协议来实现的。对 volatiile 修饰的变量执行写操作时, JVM 会发送一个 Lock 前缀指令给 CPU,CPU 在执行完写操作后,会立即将新值刷新到内存,同时因为 MESI 缓 存一致性协议,其他各个 CPU 都会对总线嗅探,看自己本地缓存中的数据是否 被别人修改,如果发现修改了,会把自己本地缓存的数据过期掉。然后这个 CPU
里的线程在读取改变量时,就会从主内存里加载最新的值了,这样就保证了可见性。
如何保证原子性
“同一时刻只有一个线程执行”我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的那么就都能保证原子性了。使用原子类来解决原子性问题,Atomic类,这些类的底层都与操作系统挂钩,在内存中修改值,Unsafe类是一个很特殊的类。
锁
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。
synchronized 是独占锁/排他锁(就是有你没我的意思),但是注意!
synchronized 并不能改变 CPU 时间片切换的特点,只是当其他线程要访问这个资源时,发现锁还未释放,所以只能在外面等待。 synchronized 一定能保证原子性,因为被 synchronized 修饰某段代码 后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行该代码,所以一 定能保证原子操作。 synchronized 也能够保证可见性和有序性。
原子变量
现在我们已经知道互斥锁可以保证原子性,也知道了如何使用
synchronized 来保证原子性。但 synchronized 并不是 JAVA 中唯一能保证原子性的方案。
如果你粗略的看一下 JUC(java.util.concurrent 包),那么你可以很显眼的 发现它俩:
一个是 locks 包,一个是 atomic 包,它们可以解决原子性问题。加锁是一种阻塞式方式实现, 原子变量是非阻塞式方式实现。
原子类
原子类原理(AtomicInteger 为例)
原子类的原子性是通过 volatile + CAS 实现原子操作的。 AtomicInteger 类中的 value 是有 volatile 关键字修饰的,这就保证了 value 的内存可见性,这为后续的 CAS 实现提供了基础。 低并发情况下:使用 AtomicInteger。
CAS
CAS(Compare-And-Swap) :比较并交换,该算法是硬件对于并发操作的支持。
CAS 是乐观锁的一种实现方式,他采用的是自旋锁的思想,是一种轻量级的锁机制。 即每次判断我的预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其他线程更新过了,因此需要拿到该最新值作为预期值,重新判断。而该 线程不断的循环判断是否该内存值已经被其他线程更新过了,这就是自旋的思 想。 底层是通过 Unsafe 类中的 compareAndSwapInt 等方法实现.
CAS 包含了三个操作数:
①内存值 V
②预估值 A (比较时,从内存中再次读到的值)
③更新值 B (更新后的值)
当且仅当预期值 A==V,将内存值 V=B,否则什么都不做。
这种做法的效率高于加锁,当判断不成功不能更新值时,不会阻塞,继续获得 cpu执行权,继续判断执行。
CAS 的缺点
CAS 使用自旋锁的方式,由于该锁会不断循环判断,因此不会类似 synchronize 线程阻塞导致线程切换。但是不断的自旋,会导致 CPU 的消耗,在并发量大的时候容易导致 CPU 跑满。
ABA 问题(添加一个版本号)
ABA 问题,即某个线程将内存值由 A 改为了 B,再由 B 改为了 A。当另外一个线程使用预期值去判断时,预期值与内存值相同, 当前线程的 CAS 操作无法分辨 当前 V 值是否发生过变化。 解决 ABA 问题的主要方式,通过使用类添加版本号,来避免 ABA 问题。如原先 的内存值为(A,1),线程将(A,1)修改为了(B,2),再由(B,2)修改 为(A,3)。此时另一个线程使用预期值(A,1)与内存值(A,3)进行比较, 只需要比较版本号 1 和 3,即可发现该内存中的数据被更新过了。