Java并发编程
1.并行与并发
单核cpu
下,线程实际还是创兴执行的。操作系统中有一个组件交左任务调度器,将cpu
的时间片(windows下时间片最小约为15毫秒)分给不同的线程使用,只是由于CPU在线程间(时间片很短)的切换非常快,给人感觉是同时运行的。
总结:微观串行,宏观并行,一般会将这种线程轮流使用cpu
的做法称为并发,concurrent
多核cpu
下,每个核(core)都可以调度运行线程,这时候线程可以是并行的。
并发(concurrent)是同一时间应对(dealing with)多件事件的能力。
并行(parallel)视同一时间动手做(doing)多件事情的能力。
2.多线程
Java是最先支持多线程的开发的语言之一,Java从一开始就支持了多线程能力。
多线程解决什么问题?
解决性能问题
多线程为什么能提升性能?
性能提升的本质 就是榨取硬件的剩余价值(硬件利用率)
3.多线程并发问题
很近以前的单 CPU 时代,单任务在一个时间点只能执行单一程序。之后发展到多任务阶段,计算机能在同一时间点并行执行多任务或多进程。虽然并不是真正意义上的“同一时间点”,而是多个任务或进程共享一个CPU,并交由操作系统来完成多任务间对CPU的运行切换,以使得每个任务都有机会获得一定的时间片运行。
后来发展到多线程技术,使得在一个程序内部能拥有多个线程并行执行。 一个线程的执行可以被认为是 CPU 在执行该程序。
多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作。这是在单线程程序中从来不会遇到的问题。其中的一些错误也未必会在单 CPU 机器上出现,因为两个线程从来不会得到真正的并行执行。然而,更现代的计算机伴随着多核 CPU 的出现,也就意味着不同的线程能被不同的 CPU 核得到真正意义的并行执行。
多线程带来的问题是什么?
安全性,性能(切换开销等)
死锁
可见性,有序性,原子性
4.Java
内存模型(JMM
)
问题
硬件的发展中,一直存在一个矛盾,CPU、内存、I/O设备的速度差异。
速度排序:CPU >> 内存 >>I/O设备
为了平衡着三者的速度差异,做了如下优化:
CPU 增加了缓存,以均衡内存与 CPU 的速度差异;
操作系统增加了进程、线程,以分时复用CPU,进而均衡I/O设备与CPU的速度差异;
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
总结:
CPU ,内存,I/O(硬盘) 三者之间的读写速度有差别
CPU 提供了缓存
任务细化到线程,切换执行
CPU 对我们指令代码的顺序进行优化(重排,有序性)
为了线程使用数据更快速,会把线程中使用到的变量复制到线程的工作内存中。线程间的数据是不可见的(可见性)
JMM
Java内存模型(Java Memory Model,JMM
)规范了Java虚拟机与计算机内存是如何协同工作的。Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型。
Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM
规范了Java虚拟机与计算机内存是如何协同工作,规定了一个线程如何以及何时可以看到由其他线程修改过后的共享变量的值,以及在必须是如何同步的访问共享变量。
计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。
在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。
JVM
主内存与工作内存
Java内存模型的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样的底层细节。
Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
这里的工作内存是JMM
的一个抽象概念,也叫本地内存,其存储了该线程以读/写共享变量的副本。
就像每个处理器内核拥有私有的高速缓存,JMM
中每个线程拥有私有的本地内存。
不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是内存共享。Java线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:
这里所说的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那就从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
5.并发编程核心问题–可见性,原子性,有序性
1.可见性
一个线程对于共享变量的修改,另外一个线程能够立即看到,我们称为可见性(目前的问题是看不到,默认是看不到的)。
对于如今的多核处理器,每个CPU都有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU缓存与内存的数据不容易保证一致。
为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中。缓存不能即时刷新导致了可见性问题。
总结:
不同线程中,有一个缓存区,缓存要操作的变量。
为了提升效率,会等所有操作完成后,再将数据写入到主内存中。
当数据还未写入到主内存时,其他线程对修改数据不可见。这就是可见性问题(可见性问题就是看不见,我们要让它可见)。
解决方法:
使用volatile关键字(下面有介绍)
2.原子性
线程切换带来的原子性问题。
原子的意思代表着——“不可拆分”;
一个或多个操作在CPU执行的过程中不被中断的特性,我们称为原子性。
原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的变量,同一时刻只能有一个线程来对它进行操作。
CPU能保证的原子操作时CPU指令级别的,而不是高级语言的操作符。线程切换导致了原子性问题。
Java并发程序都是基于多线程的,自然也会涉及到任务切换,任务切换的时机大多数是在时间片结束的时候。我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成。如count ++ ,至少需要三条CPU指令。
指令 1:首先,需要把变量 count 从内存加载到工作内存;
指令 2:之后,在工作内存执行 +1 操作;
指令 3:最后,将结果写入内存中。
总结:
多线程在多核CPU中运行,线程切换会打破原子性。
一行代码,本来是不应该被拆分执行的,但是线程切换导致其他线程也会执行,最终导致的问题,结果与预期值不一致。
3.有序性
有序性指的是程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序。
即:CPU在执行过程中,可能会对我们的代码执行顺序做出优化,重新排列指令。
Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
总结
缓存导致的可见性问题,线程切换导致的原子性问题,编译优化带来的有序性问题。其实缓存、线程切换、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序安全性和性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。
6. volatile关键字
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后:
1.保证了不同线程对这个变量进行操作的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2.禁止CPU进行指令重排序(有序性)。
3. volatile不能保证对变量操作的原子性。
用法:volatile关键字用来修饰共享变量。
7.如何保证原子性
线程并行执行时,我们需要<u>同一时刻只有一个线程执行</u>,也就是让并行执行做到并发执行(一个一个的执行),我们称之为<u>互斥</u>,如果我们能够保证对共享变量的修改是互斥的,那么就都能保证原子性了。
1.锁
锁是一种通用的技术方案,Java语言提供的 synchronized 关键字,就是锁的一种实现。
synchronized 是独占锁/排他锁/互斥锁(就是有你没我的意思),但是注意:synchronized 并不能改变CPU 时间片切换的特点,只是当其他线程要访问这个资源时,发现锁还未释放,所以只能在外面等待。
synchronized 一定能保证原子性,因为被 synchronized 修饰某段代码后,无论是单核CPU 还是多核CPU,只有一个线程能够执行该代码,所以一定能保证原子操作。即 synchronized 可以保证在一个线程执行时,其他线程不能操作共享数据。
synchronized 也能够保证可见性和有序性。
2. JUC
——原子变量
JUC
就是java.util.concurrent
工具包的简称,这是一个处理线程的工具包。它里面会提供一些类,既保证线程安全问题,还要比一般加锁的方式效率高。高并发的情况下,可以使用JUC
工具包下提供的类。
JUC
包中提供一些用于处理高并发且线程安全的类,效率高。
现在我们已经知道互斥锁可以保证原子性,也知道了如何使用 synchronized 来保证原子性。但 synchronized 并不是 JAVA 中唯一能保证原子性的方案。
如果你粗略的看一下J.U.C
(java.util.concurrent
包),那么你可以很显眼的发现它俩:
一个是locks包,一个是atomic包,它们可以解决原子性问题。
加锁是一种阻塞式方式实现。
原子变量是非阻塞式方式实现。
AtomicXXX
如:AtomicBoolean
、AtomicInteger
、AtomicIntegerArray
等
案例:
一个简单的 i++ 可以分为三步:
读取 i 的值
计算 i + 1
将计算出 i + 1 赋给 i
这就无法保证 i ++ 的原子性,即在 i++ 过程中,可能会出现其他线层也读取了 i 的值,但读取到的不是更改过后的 i 的值。
原子类原理(AtomicInteger
为例)
原子类的原子性是通过 volatile + CAS
实现原子操作的。
AtomicInteger
类中的value 是由 volatile 关键字修饰的,这就保证了value的内存可见性,这为后续的CAS
实现提供了基础。
低并发情况下:使用 AtomicInteger
。
8. CAS
CAS
(Compare-And-Swap):比较并交换,该算法是硬件对于并发操作的支持。
CAS
是乐观锁的一种实现方式,他采用的是自旋锁的思想,是一种轻量级的锁机制。是一种无锁实现方式,非阻塞式的。
适合低并发。
即每次判断预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其他线程更新过了,因此需要拿到该最新值作为预期值,重新判断。而该线程不断地循环判断是否该内存值已经被其他线程更新过了,这就是自旋的思想。
CAS
包含了三个操作数:
①内存值 V
②预期值 A(比较时,从内存中再次读到的值)
③更新值 B(更新后的值)
当且仅当预期值A==V(说明主内存数据没有其他线程修改名词是就可以把计算后的数据B写入到主内存),将内存值V=B,否则什么都不做。
这种做法的效率高于加锁,当判断不成功不能更新值时,不会阻塞,继续获得CPU执行权,继续判断执行。
CAS
的缺点
CAS
使用自旋锁的方式,由于该锁会不断循环判断,因此不会产生类似synchronized 线程阻塞导致线程切换,但是不断地自旋,会导致CPU的消耗,在并发量大的时候容易导致CPU跑满。
概括:
非阻塞式的,其他线程依然可以执行,还要自旋,导致CPU的消耗比较高,内存有压力。
造成ABA
问题
ABA
问题
ABA
问题,即某个线程将内存值由A改为了B,再由B改为了A。当另外一个线程使用预期值去判断时,预期值与内存值相同,误以为该变量没有被修改过而导致的问题。
解决ABA
问题的主要方式,通过使用类似添加版本号的方式,来避免ABA
问题。如原先的内存值为(A,1),线程将(A,1)修改为了(B,2),再由(B,2)修改为了(A,3)。此时另一个线程使用预期值(A,3)与内存值(A,1)进行比较,只需要比较版本号1和3,即可发现该内存中的数据被更新过了。
9. ConcurrentHashMap
Java 5.0 在java.util.concurrent
包中提供了多种并发容器类来改进同步容器的性能。
ConcurrentHahMap
同步容器类是 Java 5 增加的一个线程安全的哈希表。对于多线程的操作,介于HashMap
与HashTable
之间。内部采用“锁分段”机制(jdk
8弃用了分段锁,使用CAS
+ synchronized)替代Hashtable
的独占锁。进而提高性能。
此 包 还 提 供 了 设 计 用 于 多 线 程 上 下 文 中 的 Collection 实 现 : ConcurrentHashMap
,ConcurrentSkipListMap
,ConcurrentSkipListSet
. CopyOnWriteArrayList
和 CopyOnWriteArraySet
。当期望许多线程访问一个给 定 collection 时 , ConcurrentHashMap
通 常 优 于 同 步 的HashMap
,ConcurrentSkipListMap
通常优于同步的TreeMap
。当读数和遍历远远大于列表的更新数时,CopyOnWriteArrayList
优于同步的ArrayList
。
补充:
ConcurrentHahMap
:线程安全的map。
Hashtable
也是线程安全的,直接将put方法整个加锁,锁粒度大,效率低。
放弃分段锁的原因:
1.加入多个分段锁浪费内存空间。
2.生产环境中,map在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。
3.为了提高GC
的效率。
jdk
8 放弃了分段锁而是用了 Node锁,减低锁的粒度,提高性能,并使用CAS
操作来确保Node的一些操作的原子性,取代了锁。
put时首先通过 hash 找到对应链表过后,查看是否是第一个Node,如果是,直接用CAS
原则插入,无需加锁。
然后,如果不是链表第一个Node,则直接用链表第一个Node加锁,这里加的锁是 synchronized。
10. Java中的锁
Java中锁的分类并不全是指锁的状态,有的指锁的特性,有的指锁的设计。
乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
乐观锁认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断更新的方式更新数据。乐观的认为,不加锁的并发操作是没有问题的。
采用CAS
原则,更新数据时,进行判断而已,不加锁实现。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改的。哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
采用 加锁更新数据,加锁 synchronized Lock ,被称为悲观锁。
悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
悲观锁在Java中的使用,就是利用各种锁。
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS
算法,典型的例子就是原子类,通过CAS
自旋实现原子操作的更新。
共享锁/独占锁
共享锁是值该锁可被多个线程所持有,并发访问共享资源。
独占锁也叫互斥锁,是指该锁一次只能被一个线程所持有。
对于 Java ReentrantLock
,Synchronized 而言,都是独占锁。但是对于Lock 的另一个实现类 ReadWriteLock
,其读锁是共享锁,其写锁是独占锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
独占锁与共享锁也是通过AQS
来实现的,通过实现不同的方法,来实现独占或者共享。
AQS
(AbstractQueuedSynchronizer
)
类如其名,抽象的队列式的同步器,这个类在java.util.concurrent.locks
包,AQS
定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock
…
AQS
的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS
是用 CLH
队列锁(CLH
同步队列是一个FIFO双向队列,AQS
依赖它来完成同步状态的管理)实现的,即 将暂时获取不到锁的线程加入到队列中。
AQS
就是基于CLH
队列,用volatile修饰共享变量state,线程通过CAS
去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
没有获得到锁的其它线程,把他们加入到一个队列里(阻塞状态,CPU不会加载)。当锁被释放后,队列里的第一个线程获得到锁,按顺序排队获取锁。
公平锁/非公平锁
**公平锁(Fair Lock)**是指在分配锁前,检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。
等待锁的线程按顺序排队,一旦锁释放了,那么排在第一个位置的线程获得锁,执行。
**非公平锁(Nonfair Lock
)**是指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到时再排到队尾等待。
不用排队,锁释放后,那个线程抢到就执行。
因为公平锁需要在多核的情况下维护一个线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低很多.
对于 synchronized 而言,是一种非公平锁。
ReentrantLock
默认是非公平锁,但是底层可以通过 AQS
的来实现线程调度,所以可以使其变成公平锁。
读写锁(ReadWriteLock
)
读写锁的特点:
a)多个读者可以同时进行读
b)写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
c)写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。对于Java ReentrantLock
而言,他的名字就可以看出是一个可重入锁,其名字是Reentrant Lock
重新进入锁。
对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
当线程获取到外层方法锁对象时,依然可以获得内部同步方法的锁,可以进入到内部方法。否则会出现死锁问题。
一个例子:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FAdsNGqd-1628482042427)(D:\HJ2010\笔记\文档里面的图片\可重入锁.png)]
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB
不会被当前线程执行,造成死锁。
分段锁
分段锁是一种思想,用于将数据分段,并在每个分段上都会单独加锁,把锁进一步细粒度话,以提高并发效率。
偏向锁/轻量级锁/重量级锁
锁的状态:
无锁状态
偏向锁状态
轻量级锁状态
重量级锁状态
锁的状态是通过对象监视器在对象头中的字段来表明的。
四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。
这四种状态都不是Java语言中的锁,而是JVM
为了提高锁的获取与释放效率而做的优化(使用synchronized时)。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
当一直只有一个线程,一直获取锁对象,此时对象头中的锁状态改为 偏向锁,并记录线程id。同一个线程访问时,可以直接获取锁,效率高。
轻量级锁
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
在偏向锁的基础上,当第二个线程访问时,偏向锁状态 升级为 轻量级锁状态。
其他线程自旋,尝试获取锁,不会阻塞,提高效率。
重量级锁
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
当前锁状态为轻量级锁时,并发访问量增多,锁状态升级为重量级,其他线程进入到阻塞状态,不在自旋尝试。
自旋锁(SpinLock
)
自旋,一听名字就很耗CPU。
自旋锁其实并不属于锁的状态,从 Mark Word 的说明可以看到,并没有一个锁状态叫自旋锁。所谓自旋其实指的就是自己重试,当线程抢锁失败后,重试几次,要是抢到锁了就继续,要是抢不到就阻塞线程。说白了还是为了尽量不要阻塞线程。
由此可见,自旋锁是是比较消耗 CPU 的,因为要不断的循环重试,不会释放 CPU资源。另外,加锁时间普遍较短的场景非常适合自旋锁,可以极大提高锁的效率。
不断重试去抢占CPU,不会阻塞线程,但是数量很多,就会耗CPU。
11. synchronized 实现
synchronized 是由JVM
实现的一种实现互斥同步的一种方式,被synchronized 修饰过的程序块,在编译前后被编译器生成了 monitorenter
和monitorexit
两个字节码指令(在同步代码块前后添加两个监视器,利用对象头记录锁是否被使用)。
在虚拟机执行到monitorenter
指令时,首先要尝试获取对象的锁。
如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器 +1;当执行monitorexit
指令时将模拟计算器 -1;当计数器为0时,锁就被释放了。
如果获取对象失败了,那么当前线程就阻塞等待,直到对象锁被另外一个线程释放为止。
Java 中 Synchronize 通过在对象头设置标记,达到了获取锁和释放锁的目的。
特点:使用一个唯一的对象,作为锁状态的标记。
Java对象头
在Hotspot
虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java对象头里。它是轻量级锁和偏向锁的关键。
Mark Word:
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode
)、GC
分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就 是 32bit
),下面就是对象头的一些信息:
12. ReentrantLock
ReentrantLock
主要利用 CAS
+AQS
队列来实现。它支持公平锁和非公平锁,两者的实现类似。
在内部有一个状态,比如默认为0,如果有线程获取到了锁,将状态改为1。
其他线程有两种处理方式,公平锁和非公平锁。
如果使用公平锁,会将等待线程添加到同步等待队列中。
使用:用它自身对象去调用方法
Lock lock = new ReentrantLock();
//用它自身对象去调用方法
lock.lock(); // 使用锁
lock.unlock(); // 释放锁