Java并发和多线程-理论基础&线程基础
理论基础
- 多线程的出现是要解决什么问题的?
- 线程不安全是指什么? 举例说明
- 并发出现线程不安全的本质什么? 可见性,原子性和有序性。
- Java是怎么解决并发问题的? 3个关键字,JMM和8个Happens-Before
- 线程安全是不是非真即假? 不是
- 线程安全有哪些实现思路?
- 如何理解并发和并行的区别?
为什么需要多线程
CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;// 导致
可见性
问题 - 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致
原子性
问题 - 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致
有序性
问题
优点
- 资源利用率更好
- 程序设计在某些情况下更简单
- 程序响应更快
缺点 - 设计更复杂
- 上下文切换的开销
- 增加资源消耗
线程不安全是指什么
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
例如,1000个线程同时对 一个int变量 执行自增操作,操作结束之后它的值有可能小于1000。
1 | public class ThreadUnsafeExample { |
并发出现问题的根源: 并发三要素
上述示例输出为什么不是1000? 并发出现问题的根源是什么?
可见性 CPU缓存引起
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
1 | //线程1执行的代码 |
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
原子性 分时复用引起
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
1 | int i = 1; |
这里需要注意的是:i += 1
需要三条 CPU 指令
- 将变量 i 从内存读取到 CPU寄存器;
- 在CPU寄存器中执行 i + 1 操作;
- 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。
有序性 重排序引起
有序性:即程序执行的顺序按照代码的先后顺序执行。
1 | int i = 0; |
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JAVA是怎么解决并发问题的: JMM(Java内存模型)
理解的第一个维度:核心知识点
JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
具体来说,这些方法包括:- volatile、synchronized 和 final 三个关键字
- Happens-Before 规则
理解的第二个维度:可见性,有序性,原子性
原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。请分析以下哪些操作是原子性操作:
1
2
3
4x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1; //语句4: 同语句3上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
有序性
在Java里面,可以通过volatile关键字来保证一定的“有序性”。
另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
当然JMM是通过Happens-Before 规则来保证有序性的。
Happens-Before 规则
JVM 规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
- 单一线程原则
在一个线程内,在程序前面的操作先行发生于后面的操作。
- 管程锁定规则
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- volatile 变量规则
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动规则
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
- 线程加入规则
Thread 对象的结束先行发生于 join() 方法返回。
- 线程中断规则
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。 - 对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。 - 传递性
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
线程安全: 不是一个非真即假的命题
一个类在可以被多个线程安全调用时就是线程安全的。
线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。
多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变的类型:
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
相对线程安全
相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。
Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
线程基础
线程状态转换
状态 | 释义 |
---|---|
NEW | 一个创建了但还没有开始启动的线程 |
RUNNABLE | 线程start后变为可执行状态,具体是否执行取决于系统cpu调度,ready/running |
BLOCKED | 阻塞,等待锁,比如等待进入synchronized代码块 |
WAITING | 线程里调用了wait/join等方法后进入等待状态 |
TIMED_WAITING | 超时等待,类似WAITING,但是有时间限制,时间到了,自动进入RUNNABLE状态 |
TERMINATED | 结束 |
Thread.sleep()
当前线程调用Thread.sleep(1000)陷入休眠,进入TIMED_WAITING状态,同时系统内核中会根据sleep中的参数设置一个定时器,定时器倒计时结束后,内核会重新唤醒线程,线程状态进入RUNNABLE状态;
Thread.yield()
线程状态在RUNNABLE状态下,由系统cpu决定是否执行,所以该状态下,线程在内核中实际有“运行中”和“就绪”两种状态。
当前线程在“运行中”时,调用Thread.yield(),会立即让出cpu的使用权,让cpu执行优先级更高的或其它同优先级的线程,线程从RUNNABLE状态下的“运行中”变为“就绪”。
Thread.join()
内部其实就是wait方法,不同于wait的是,它会主动等使用了Object的锁对象的线程彻底执行结束后,自动从WAITING状态进入RUNNABLE状态。
Object.wait()
当前线程获取Object锁后,调用Object的wait方法,则会使当前线程进入WAITING或TIMED_WAITING状态,并释放Object的持有锁,当前线程会被放入等待队列中,直到超时或者被其他线程调用锁对象的notify方法唤醒。
Object.notify()/notifyAll()
当前线程获取Object锁后,调用Object的notify/notifyAll方法,会使此前调用了该Object的wait线程从WAITING状态进入RUNNABLE状态。
notify只会唤醒一个线程,而notifyAll方法可以唤醒所有线程。
notify()或者notifyAll()调用时并不会真正释放对象锁, 必须等到synchronized方法或者语法块执行完才真正释放锁。
线程使用方式
1. 继承Thread类
继承Thread类,重写run()方法,调用start()方法
2. 实现Runnable接口
实现Runnable接口并实现run()方法,在Thread类的构造函数中传入实现Runnable接口的类的实例对象
3. 实现Callable接口,通过FutureTask来封装
实现Callable接口并实现run()方法
与 Runnable 相比,Callable 可以有返回值,返回值通过FutureTask进行封装。
Callable负责产生结果,Future负责获取结果。
Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果。
Future.get()方法会导致主线程阻塞,直到Callable任务执行完成。
1 | public class MyCallable implements Callable<Integer> { |
4. 线程池
线程中断
一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。
线程中断是一种线程间协作的机制。
当一个线程在执行过程中受到中断请求时,它可以选择立即响应中断,也可以稍后响应,或者根本不响应。
中断机制是一种软件层面上的线程协作机制,线程可以自己决定何时以及如何响应中断。
interrupt() & InterruptedException
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞block、限期等待time_waiting或者无限期等待waiting状态,那么就会抛出 InterruptedException,从而提前结束该线程。
1 | public class InterruptExample { |
interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等能够抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt()
方法会设置线程的中断标记,此时调用 interrupted()
方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
1 | public class InterruptExample { |
synchronized VS ReentrantLock
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。
比较
- 锁的实现
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。 - 性能
新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。 - 等待可中断
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
ReentrantLock 可中断,而 synchronized 不行。 - 公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。 - 锁绑定多个条件
一个 ReentrantLock 可以同时绑定多个 Condition 对象。
使用选择
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。
这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。
并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。