什么是多线程并发编程
并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束,而并行是说在单位时间内多个任务同时在执行。
并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行。
为什么要进行多线程并发编程
多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。 多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。
Java中的线程安全问题
资源共享,是指该资源被多个线程所持有或者说多个线程都可以去访问该资源。
线程安全问题则是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。且只有当至少一个线程修改共享资源时才会出现线程安全问题。
Java中共享变量的内存可见性问题
Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。
当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存, 然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。
我们通过上图的一个例子来解释内存不可见性问题:
假如线程 A 和线程 B 同时处理一个共享变量,会出现什么情况?我们使用图 2-5 所示 CPU 架构, 假设线程 A 和线程 B 使用不同 CPU 执行,并且当前两级 Cache 都为空, 那么这时候由于 Cache 的存在,将会导致内存不可见问题
- 线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中 ,所以加载主内存中 X 的值,假如为 0。然后把 X = 0 的值缓存到两级缓存, 线程 A 修改 X 的值为 1, 然后将其写入两级 Cache, 并且刷新到主内存。 线程 A 操作完毕后,线程 A 所在的 CPU 的两级 Cache 内和主内存里面的 X 的值都是 1。
- 线程 B 获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回 X = 1 ;到这里一切都是正常的,因为这时候主内存中也是 X = 1 。然后线程 B 修改 X 的值为 2, 并将其存放到线程 B 所在的一级 Cache 和共享二级 Cache 中, 最后更新主内存中 X 的值为 2 ;到这里一切都是好的。
- 线程 A 这次又需要修改 X 的值,获取时一级缓存命中,并且 X = 1 ,到这里问题就出现了,明明线程 B 已经把 X 的值修改为了 2,为何线程 A 获取的还是 1 呢? 这就是共享变量的内存不可见问题,也就是线程 B 写入的值对线程 A 不可见。
synchronized关键字
synchronized关键字的介绍
synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
synchronized的内存语义
我们来看 synchronized 的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。
- 进入 synchronized 代码块时:把 synchronized 块内使用到的共享变量从线程的工作内存中清除,这样该变量就会从主内存中读取。
- 退出 synchronized 代码块时:把 synchronized 块内对共享变量的修改刷新到主内存中。
Java中的volatile关键字
对于解决内存可见性问题,Java还提供了一种弱形式的同步,这就是 volatile 关键字。
当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。
当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
volatile 相比于 synchronized 有一些好处,比如说可以减少线程上下文切换带来的开销。同时 volatile 是非阻塞式的,而 synchronized 是阻塞式的。
volatile 虽然提供了可见性保证,但并不保证操作的原子性。那么一般在什么时候才使用 volatile 关键字呢?
- 写入变量值不依赖变量的当前值时。 因为如果依赖当前值,将是获取一计算一写入三步操作,这三步操作不是原子性的,而 volatile 不保证原子性。 比如说 i++ 。
- 读写变量值时没有加锁。 因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile 的。
Java中的原子性操作
所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。 比如 i++ 就不具有原子性。
那么如何保证多个操作的原子性呢?最简单的方法就是使用 synchronized 关键字进行同步。
1 | public class ThreadSafeCount { |
使用 synchronized 关键宇的确可以实现线程安全性,即内存可见性和原子性,但是 synchronized 是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的 getCount 方法只是读操作,多个线程同时调用不会存在线程安全问题。 但是加了关键宇 synchronized 后,同 一时间就只能有一个线程可以调用,这显然大大降低了并发性。你也许会间,既然是只读操作,那为何不去掉 getCount 方法上的 synchronized 关键字呢?其实是不能去掉的,别忘 了这里要靠 synchronized 来实现 value 的内存可见性。
Java中的CAS操作
CAS 即 Compare and Swap,其是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。 JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap*方法,下面以 compareAndSwapLong 方法为例进行简单介绍。
boolean compareAndSwapLong(Object obj, long valueOffset, long expect, long update)方 法 : 其中 compareAndSwap 的意思是比较并交换。CAS 有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、 变量预期值和新的值。其操作含义是,如果对象 obj 中内存偏移量为 valueOffset 的变量值为 expect,则使用新的值 update 替换旧的值 expect。这是处理器提供的一个原子性指令。
关于 CAS 操作有个经典的 ABA 问题,具体如下:假如线程 I 使用 CAS 修改初始值为 A 的变量 X, 那么线程 I 会首先去获取当前变量 X 的值(为 A),然后使用 CAS 操作尝试修改 X 的值为 B,如果使用 CAS 操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程 I 获取变量 X 的值 A 后,在执行 CAS 前,线程 II 使用 CAS 修改了变量 X 的值为 B,然后又使用 CAS 修改了变量 X 的值为 A。 所以虽然线程 I 执行 CAS 时 X 的值是 A, 但是这个 A 己经不是线程 I 获取时的 A 了。这就是 ABA 问题。
ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从 A 到 B, 然后再从 B 到 A。如果变量的值只能朝着一个方向转换,比如 A 到 B , B 到 C,不构成环形,就不会存在问题。JDK 中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了 ABA 问题的产生。
Unsafe类
1 | import java.lang.reflect.Field; |
Java指令重排
Java 内存模型允许编译器和处理器对指令重排序以提高运行性能, 并且只会对不存在数据依赖性的指令重排序。 在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。
多线程下如何避免指令重排的问题?可以使用 volatile 来防止指令重排:
- 写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
- 读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
锁的概述
乐观锁与悲观锁
- 悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
- 乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。一般在表中添加 version 字段或使用业务状态来实现。
公平锁与非公平锁
- 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。
- 而非公平锁则在运行时闯入,也就是先来不一定先得。
ReentrantLock 提供了公平和非公平锁的实现。
- 公平锁:
ReentrantLock pairLock =new ReentrantLock(true)
- 非公平锁:
ReentrantLock pairLock =new ReentrantLock(false)
。 如果构造函数不传递参数,则默认是非公平锁。
独占锁与共享锁
- 独占锁保证任何时候都只有一个线程能得到锁, ReentrantLock 就是以独占方式实现的。
- 共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。
可重入锁
当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线 程再次获取它自己己经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数(在高级篇中我们将知道,严格来说是有限次数)地进入被该锁锁住的代码。
可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0, 说明该锁没有被任何线程占用 。 当一个钱程获取了该锁时,计数器的值会变成 1 ,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1, 当释放锁后计数器值 -1 。 当计数器值为 0 时,锁里面的线程标示被重置为 null, 这时候被阻塞的线程会被唤醒来竞争获取该锁。
自旋锁
自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用 -XX:PreB lockS pinsh 参数设置该值),很有可能在后面几次尝试中其他线程己经释放了锁。