Java并发编程
1.并行与并发
**并发:**多个任务在同一个CPU核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。在单核CPU下,线程实际还是串行执行的。
**并行:**单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
2.多线程
多线程:多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务。
**优点:**可以提高 CPU 的利用率。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
缺点:
- 线程也是程序,所以线程需要占用内存,线程越多占用内存也越多;
- 多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
- 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。
3.java内存模型(JMM)
问题
硬件的发展中,一直存在一个矛盾,CPU、内存、I/O 设备的速度差异。
速度排序:CPU >> 内存 >> I/O 设备
为了平衡这三者的速度差异,做了如下优化:
- CPU 增加了缓存,以均衡内存与 CPU 的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 I/O 设备与 CPU的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
Java内存模型(JMM)
Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的(规范了 Java 虚拟机与计算机内存是如何协同工作,规定了一个线程如何以及何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量)。Java 虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为 Java 内存模型。
作用:用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运 行,当运算结束后再从缓存同步回内存之中。
在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己 的高速缓存,它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。
JVM主内存与工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。
Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工 作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。
就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。
不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:
4.并发编程的核心问题
1.可见性
一个线程对共享变量的修改,另一个线程能够立刻看到,称为可见性。
问题的出现:
为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中。 缓存不能及时刷新导致了可见性问题。
举例:假设线程 1 和线程 2 同时开始执行,那么第一次都会将 a=0 读到各自的 CPU 缓存里,线程 1 执行 a++之后 a=1,但是此时线程 2 是看不到线程 1 中 a 的值的,所以线程 2 里 a=0,执行 a++后 a=1。
线程 1 和线程 2 各自 CPU 缓存里的值都是 1,之后线程 1 和线程 2 都会将自己缓存中的 a=1 写入内存,导致内存中 a=1,而不是我们期望的 2。
2.原子性
一个或多个操作在CPU执行的过程中不被中断的特性,称为原子性。
原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作.
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。线程切换导致了原子性问题
3.有序性
有序性指的是程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序。
总结
缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题。
volatile关键字
一旦一个共享变量被volatile修饰后:
- 保证了不同线程对这个变量进行操作时的可见性,及一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
- volatile不能保证对变量操作的原子性。
如何保证原子性?
”同一时刻只有一个线程执行“我们称之为互斥。如果我们能保证对共享变量的修改是互斥的那么就能保证原子性了。
-
锁(阻塞式方式实现)
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。
synchronized 是独占锁/排他锁(就是有你没我的意思),但是注意!synchronized 并不能改变 CPU 时间片切换的特点,只是当其他线程要访问这个 资源时,发现锁还未释放,所以只能在外面等待。
synchronized 一定能保证原子性,因为被 synchronized 修饰某段代码后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行该代码,所以一定能保证原子操作.
synchronized 也能够保证可见性和有序性。
-
JUC-原子变量(非阻塞式方式实现)
属于java.util.concurrent.atomic
原子类的原子性是通过volatile+CAS实现原子操作的。
AtomicInteger类中的value是又volatile关键字修饰的,保证了value的内存可见性。
低并发情况下:使用AtomicInteger
CAS
CAS:比较并交换,该算法是硬件对与并发操作的支持。
CAS是乐观锁的一种实现方式,它采用的是自旋锁的思想,是一种轻量级的锁机制。
过程:每次判断我的预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其他线程更新过了,因此需要拿到该最新值作为预期值,重新判断。而该线程不断的循环判断是否内存值已经被其他线程更新过了,这就是自旋的思想。
CAS包含了三个操作数:
- 内存值 V
- 预估值 A(比较时,从内存中再次读到的值)
- 更新值 B(更新后的值)
当且仅当预期值A==V,将内存值V=B,否则什么都不做。
优点:效率高于枷锁。
缺点:
-
不断的自旋,会导致CPU的消耗,在并发量大的时候容易导致CPU跑满。
-
ABA问题
某个线程将内存值由A改为了B,再由B改为了A。当另一个线程使用预估值去判断时,预期值与内存值相同,误以为该变量没有被修改过而导致的问题。
**解决方法:**通过使用类似添加版本号的方式,来避免。如原先的内存值为(A,1),线程将(A,1)修改为了(B,2),再由(B,2)修改为(A,3)。此时另一个线程使用预期值(A,1)与内存值(A,3)进行比较,只需要比较版本号 1 和 3,即可发现该内存中的数据被更新过了。
ConcurrentHashMap
Java5后在Java.utilconcurrent包中增加的线程安全的哈希表。
对于多线程的操作,介于HashMap与Hashtable之间。内部采用“锁分段”机制(JDK8弃用了分段锁,使用CAS+synchronized)替代Hashtable的独占锁。
在Java.utilconcurrent包中提 供 了 设 计 用 于 多 线 程 上 下 文 中 的 Collection 实 现 :ConcurrentHashMap,ConcurrentSkipListMap,ConcurrentSkipListSet. CopyOnWriteArrayList 和 CopyOnWriteArraySet。当期望许多线程访问一个给定collection时,ConcurrentHashMap>同步的HashMap
,ConcurrentSkipListMap>同步的TreeMap。
当读数和遍历远远大于列表的更新数时,CopeOnWriteArrayList>同步的ArrayList。
放弃分段锁的原因
- 加入多个分段锁浪费内存空间。
- 生产环境中,map再放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。
- 为了提高GC效率。
所以在JDK8放弃了分段锁采用了Node锁,减低锁的粒度,提高性能,并使用CAS操作来确保Node的一些操作的原子性,取代了锁。
**操作:**put时首先通过hash找到对应链表后,查看是否是第一个Node,如果是,直接用CAS原则插入,无需加锁。如果不是链表的第一个Node,则直接用链表第一个Node加锁,这里加的锁是synchronized。
Java中锁
AQS
抽象的队列式的同步器。
核心思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁(CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理)实现的,即将暂时获取不到锁的线程加入到队列中。
乐观锁/悲观锁
乐观锁:认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
悲观锁:认为对同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。
悲观锁适合写操作。利用各种锁。
乐观锁适合读操作。无锁编程。采用CAS算法。
共享锁/独占锁
共享锁是指该锁可被多个线程所持有,并发访问共享资源。
独占锁(互斥锁)是指该锁一次只能被一个线程所持有。
对于 Java ReentrantLock,Synchronized 而言,都是独享锁。但是对于 Lock的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
独享锁与共享锁是通过AQS来实现的。
公平锁/非公平锁
公平锁是指在分配锁前,检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。
非公平锁是指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时再排到队尾等待。
因为公平锁需要在多核的情况下维护一个线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低很多。
对于synchronized而言,是一种非公平锁。
ReentrantLock默认是非公平锁,但是底层可以通过AQS的来实现线程调度,所以可以使其变成公平锁。
可重入锁(递归锁)
可重入锁是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
ReetrantLock、Synchronized是一个可冲入锁。
优点:一定程度避免死锁。
比如
如果不是可重入锁的话,setB不会被当前线程执行,造成死锁。
读写锁
特点:
- 多个读者可以同时进行读。
- 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
- 写着优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
分段锁
分段锁是一种思想,用于将数据分段,并在每个分段上都会单独加锁,把锁进一步细粒度化,以提高并发效率。
偏向锁/轻量级锁/重量级锁
锁的状态:锁的状态是通过对象监视器在对象头中的字段来表明的。
-
无锁状态
-
偏向锁状态
是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。
-
轻量级锁状态
是指当锁是偏向锁的时候,被另一个线程锁访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋转的形式尝试获取锁,不会阻塞,提高性能。
-
重量级锁状态
是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。
这四种状态都不是Java语言中的锁,而是JVM为了提高锁的获取与释放效率而做的优化(使用synchronized时)。
自旋锁(SpinLock)
指的是当线程抢锁失败后,重试几次,要是抢到了锁就继续,要是抢不到就阻塞线程。
自旋锁比较消耗CPU,适合加锁时间普遍较短的场景。
Synchronized实现
Synchronized是由JVM实现的一种实现互斥同步的一种方式,被修饰的程序块,编译前后被编译器生成了monitor enter和monitor exit两个指令。
过程:
- 在虚拟机执行到monitor enter指令时,首先要尝试获取对象的锁。
- 如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1;当执行monitor exit指令时将模计数器-1;当计数器为0时,锁就被释放了。
- 如果获取对象失败了,那当前线程就阻塞等待,直到对象锁被另外一个线程释放为止。
Java中Synchronized通过在对象头设置标记,达到了获取锁和释放锁的目的。
Java对象头
在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现Synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。
Mawrk Word
用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。Java对象头一般占有两个机器码。
对象头信息:
ReentrantLock
ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。
lock():
假设当前有三个线程区竞争锁,假设线程A的CAS操作成功了,获得了锁,那么线程B和C则设置状态失败。
由于线程A已经占用了锁,所以B和C失败,并且入等待队列。如果线程A拿着锁死死不放,那么B和C就会被挂起。
B和C相继入队尝试获取锁。
若当前线程的节点的前驱节点是head,就有资格尝试获取锁。
unlock():
尝试释放锁,若释放成功,那么查看头结点的状态,如果是则唤醒头结点的下个节点关联的线程。
ReentrantLock和Synchronized区别
类别 | Synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 是一个类 |
锁的释放 | 1.以获取锁的线程执行完同步代码,释放锁2.线程执行发生异常,jvm会让线程释放锁 | 在finally中必须释放锁,不然容易造成线程死锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待。 | 分情况而定,Lock有多个锁获取的方式,可以尝试获得锁,线程可以不用一直等待。 |
锁状态 | 无法判断 | 可以判断 |
锁类型 | 可重入 不可中断 非公平 | 可重入 可判断 可公平可非公平 |
性能 | 少量同步 | 大量同步 |