JAVA多线程并发
Java 多线程三大核心
原子性
在并发编程中,原子性的定义不应该和事务中的原子性(一旦代码运行异常可以回滚)一样。应该理解为:一段代码,或者一个变量的操作,在一个线程没有执行完之前,不能被其他线程执行。
- 原子操作是对于多线程而言的,对于单一线程,无所谓原子性。
- 原子操作是针对共享变量的。
- 原子操作是不可分割的。指访问某个共享变量的操作从其执行线程之外的线程来看,该操作要么已经执行完毕,要么尚未发生,其他线程不会看到执行操作的中间结果。
注意:
java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条先对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32位的操作来执行,即允许虚拟机实现选择可以不保证64位数据类型的read、store、load和write这4个操作的原子性。这就是所谓的long和double的非原子性协定。(以上针对于32位虚拟机来讲的,64位JVM虚拟机不用考虑了,不存在这种情况;)如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据。
为什么会存在原子性问题?
线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
例如:代码中的count += 1,至少需要三条CPU指令。
- 指令1:首先,需要把变量count从内存加载到CPU的寄存器;
- 指令2:之后,在寄存器中执行+1操作;
- 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU指令执行完,是的,是CPU指令,而不是高级语言里的一条语句。
对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1。
如果是基础类的自增操作可以使用 AtomicInteger 这样的原子类来实现(其本质是利用了 CPU 级别的 的 CAS 指令来完成的)。
可见性
volatile保证了不同线程对volatile修饰变量进行操作时的可见性。volatile 关键字只能保证可见性,顺序性,不能保证原子性。
对一个volatile变量的读,(任意线程)总是能看到对这个volatile变量最后的写入。
一个线程修改volatile变量的值时,该变量的新值会立即刷新到主内存中,这个新值对其他线程来说是立即可见的。
一个线程读取volatile变量的值时,该变量在本地内存中缓存无效,需要到主内存中读取。
顺序性
程序执行的顺序按照代码的先后顺序执行。导致乱序的原因有:指令的重排序和存储子系统的重排序。分别来自编译器处理器和高速缓存写缓冲器。
编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
例如单例设计模式:
public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }
instance = new Singleton(); 创建对象的代码,分为三步:
①分配内存空间
②初始化对象Singleton
③将内存空间的地址赋值给instance
但是这三步经过重排之后:
①分配内存空间
②将内存空间的地址赋值给instance
③初始化对象Singleton
执行时序图:
线程A先执行getInstance()方法,当执行完指令②时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。