1. 并发和并行
- 并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束。并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间积累而成,所以说并发的多个任务在单位时间内不一定同时在执行。
- 并行是说在单位时间内多个任务同时在执行
2. Java的内存模型
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫做工作内存,线程读写变量时操作的是自己工作内存中的变量。
3. synchronized关键字
(1) synchronized关键字介绍
synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当做一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问改同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
(2) synchronized的内存语义
- 进入synchronized块的内存语义是把synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作过内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。
- 其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本底内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本底内存中修改的共享变量刷新到主内存。
- 除了可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。
- 注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。
4. volatile关键字
(1) 与synchronized不同之处
-
这是一种弱形式的同步。
-
synchronized太笨重,因为它会带来线程上下文的切换开销。
-
volatile关键字可以确保一个变量的更新对其它线程马上可见。
-
当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新会主内存。当其它线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
-
不保证操作的原子性
(2) 与synchronized相似之处
- 当线程写入volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存)
- 读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)
(3) 什么时候使用volatile
- 写入变量值不依赖变量值的当前值时。因为如果依赖当前值,将是获取——计算——写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
- 读写变量值时没有加锁。因为加锁本身已经保证内存可见性,这时候不需要把变量声明为volatile的。
5. Java中的CAS操作
(1) CAS概念
- Compare and Swap,其是JDK提供的非阻塞原子性操作
- 它通过硬件保证了比较——更新操作的原子性
(2) compareAndSwapLong方法介绍
boolean compareAndSwapLong(Object obj, long valueOffset, long expect, long update)
- compareAndSwap的意思是比较并交换
- CAS有四个操作数
- 对象内存位置
- 对象中的变量的偏移量
- 变量预期值
- 新的值
- CAS操作含义:如果对象obj中内存偏移量valueOffset的变量值为expect,则使用新的值update替换旧的值expect,然后返回true。这就是处理器提供的一个原子性指令
(3) 经典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问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从A到B,然后再从B到A。如果变量的值只能朝着一个方向转换,比如A到B,B到C,不构成环形,就不会存在问题。JDK中的AtomicStampedReference类给每个变量的状态值都配备了一个时间戳,从而避免了ABA问题的产生。
6. Java指令重排序
- Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与线程顺序执行的结果一致,但是多线程下就会存在问题。
- 写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。
7. 伪共享
(1) 什么是伪共享
- 为解决计算机系统中主内存与CPU之间运行速度差问题,会在CPU与主内存之间添加一级或者多级高速缓冲存储器(Cache)。这个Cache一般是被集成到CPU内部的,所以也叫CPU Cache。
- 在Cache内部是按行存储的,其中每一行称为一个Cache行。Cache行是Cache与主内存进行数据交换的单位,Cache行的大小一般为2的幂次数字节。
- 当CPU访问某个变量时i,首先会去看CPU Cache内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache行大小的内存复制到Cache中。由于存放到Cache行的是内存块而不是单个变量,所以可能会把多个变量存放到一个Cache行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是伪共享。
(2) 如何避免伪共享
- 在JDK8之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中。
8. 锁的概述
(1) 乐观锁与悲观锁
- 悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其它线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其他线程修修改,当前线程则等待或者抛出异常。如果获取成功,则对记录进行操作,然后提交事务后释放排它锁。
- 乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。乐观锁并不会使用数据库提供的锁机制,一般在表中添加verison字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。
(2) 公平锁与非公平锁
- 根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁
- 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁
ReentrantLock pairLock = new ReentrantLock(true)
- 非公平锁则是在运行时闯入,也就是先来不一定先得
ReentrantLock pairLock = new ReentrantLock(false)
如果构造函数不传递参数,则默认是非公平锁。
- 在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
(4) 独占锁与共享锁
- 根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁
- 独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock就是以独占方式实现的。
- 独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一个时间由一个线程读取数据,其它线程必须等待当前线程释放锁才能进行读取。
- 共享锁则可以同时由多个线程持有,例如ReadWriteLock读写锁,它允许一个资源可以被多线程同时进行读操作。
- 共享锁是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
(5) 什么是可重入锁
- 当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁时可重入的,也就是只要该线程获取了该锁,那么可以无限次(严格来说是有限次)地进入被该锁锁住的代码。
- 实际上,synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁没有被任何线程占用。
- 当一个线程获取了该锁时,计数器的值会变成1,这时其它线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
- 但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1,当释放锁后计数器值-1.当计数器值为0时,锁里面的线程标示被重置为null,这时候被阻塞的线程会被唤醒来竞争获取该锁。
9. 自旋锁
- 由于Java中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。当线程获取到锁时有需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
- 自旋锁则是,当前线程在获取锁时,如果发现锁已经被其它线程占有,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是10,可以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其它线程已经释放了该锁。如果尝试指定的次数后仍没有获取到该锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间白白浪费。