1、内存模型JMM(Java Memory Model)
我们以一个最简单的例子开始
int i = 5;
i = i + 1;
i=i+1这条语句,虽然看起来只有一步,但是从微观的角度可以将它分解为以下几步
(1)从内存中读取i=5,并复制到cpu缓存中
(2)将cpu缓存中i的值+1,现在cpu缓存中i=6,而内存中i=5
(3)将cpu缓存中的i刷新到内存中,此时i=6
简单一句话就是,赋值会先刷到缓存再刷到内存,取值会直接从内存取。
2、导致并发问题的三个原因,以及JVM提供的解决方案
2.1、可见性问题
2.1.1、问题描述
由上面内存模型的例子,我们可以引出一个问题,如果我们两个线程执行i=i+1这个操作,期望的最终结果i=7,但是如果两个线程同时执行了(1)呢?我们知道多核CPU中的缓存是独立的、不共享的。两个线程会同时将i=5刷到自己的缓存中,并分别执行i=i+1,再刷回内存,结果是i=6,这就是缓存一致性问题,也就是线程间缓存不可见导致的可见性问题
2.1.2、解决方案------volatile+Happens-Before原则
首先介绍一下内存屏障(Memory Barrier)的概念:
内存屏障也叫内存栅栏,是一条cpu指令,用来影响数据可见性。内存屏障分为两种:
(1)写内存屏障(Store-Barrier)
在写一个变量时加内存屏障,将写入的变量立即从缓存刷入内存
(2)读内存屏障(Load-Barrier)
在读一个变量时加内存屏障,使该变量的缓存失效饼从内存中读取
volatile是“易变的”的意思,修饰在属性(常量/成员变量)上表示通知JVM该属性可能不太稳定,会在读写该变量的时候增加内存屏障,在写该变量时直接将缓存刷入内存,在读该变量时使缓存失效并直接从内存读。
接下来介绍一下Happens-Before原则:
Happens-Before的意思是,如果A Happens-Before B,则A的操作结果对B可见,它一共有8个原则:
(1)程序次序规则:
一个线程内一段代码的执行结果是有序的。即两行代码先后执行,先执行的代码产生的结果对后执行的代码可见。
(2)管程锁定规则:
对一个锁的解锁Happens-Before于后续对这个锁的加锁。即上一轮的加锁解锁产生的结果对下一轮加锁解锁中的操作可见。
(3)volatile变量规则:
对一个volatile变量的写操作Happens-Before于后续对这个变量的读操作。
(4)线程启动规则:
主线程启动的操作Happens-Before于子线程。即主线程在启动子线程前的操作对子线程可见。
(5)线程终止规则:
子线程终止前的操作Happens-Before于主线程。即子线程的操作对主线程可见。
(6)线程中断规则:
调用interrupt方法Happens-Before于检测到中断事件。即对一个线程执行interrupt方法的结果,对被中断线程检测到中断状态Thread.isInterrupted之前可见
(7)传递规则:
A Happens-Before B,B Happens-Before C,则 A Happens-Before C.
(8)对象终结规则:
一个对象的初始化操作Happens-Before于销毁操作。
2.2、有序性问题
2.2.1、问题描述
双重锁实现单例模式:
public class Singleton {
private Singleton() { }
private static Singleton instance;
public static Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
这段代码中new Singleton()的执行顺序本来应该是这样的:
JVM开一块内存空间--->在这块内存空间上初始化Singleton对象--->把内存空间地址赋值给instance
但其实编译器在编译过程中会对指令进行重排序,执行顺序有可能是这样的:
JVM开一块内存空间--->把内存空间地址赋值给instance--->在这块内存空间上初始化Singleton对象
虽然两种执行顺序在单线程中结果是正常的,但是后者在多线程中会出现问题:
假如A线程执行到了new Singleton(),A把空间地址赋值给instance,这时候B线程进来了,instance==null就是false,但是实际上对象还没初始化,就会造成调用方法空指针
2.2.2、解决方案------volatile+Happens-Before原则
volatile还有禁用编译优化的功能
2.3、原子性问题
2.3.1、问题描述
最经典的取钱问题,假如一个账户只有500元,A和B两个人同时对这个账户进行取500元的操作,分别对应一个进程里两个不同的线程,而账户里的钱对应共享资源
A取钱:检测账户里有500元---->取出500元
B取钱,检测账户里有500元---->取出500元
如果A和B同时执行检测账户余额的操作,就会同时执行取钱的操作,此时账户余额就会出现-500的情况
原子性:我们把一个或多个操作在CPU中执行的过程中不被中断的特性叫原子性,可以认为原子性问题就是由多个线程(A、B两个人)同时对共享资源(账户)进行操作(取钱)导致的。
取钱过程分为检测余额和取钱两步,这两步是不可分割的,但是由于A和B是两个不同的线程,就有可能在检测余额的时候出现“线程切换”的操作,破坏了取钱这个过程应具有的原子性
2.3.2、解决方案------synchronized关键字
首先synchronized是一种互斥锁,即同一时刻只能有一个线程访问临界资源
synchronized是同步的意思,用来告诉JVM多个线程必须同步执行该段代码。
synchronized可以修饰成员方法和静态方法,分别加对象锁和类锁;还可以修饰方法内的方法块,需要自己指定加锁类型,注意对同一个变量的读写操作一定要加同一种锁
package com.lcy.thread.part41;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019-08-04 14:55
*/
public class Test {
//修饰类方法
private static synchronized void test1(){
//修饰代码块,加类锁
synchronized (Test.class){
}
}
//修饰成员方法
private synchronized void test2(){
//修饰代码块,加对象锁
synchronized (this){
}
}
}
2.3.2.1、粗粒度锁
粗粒度锁,即用同一把锁保护多个不同的临界资源,优点就是实现容易,缺点就是所有操作串行,性能低:
package com.lcy.thread.part41;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019/9/3 09:44
*/
public class Account {
//保护锁
private final Object lock = new Object();
//账户余额
private Integer balance;
//账户信息
private String userInfo;
//存款
private void addBalance(Integer amt) {
synchronized (lock) {
balance += amt;
}
}
//取款
private void subBalance(Integer amt){
synchronized (lock){
if(balance > amt){
balance -= amt;
}
}
}
//设置账户信息
private void setUserInfo(String newUserInfo){
synchronized (lock){
userInfo = newUserInfo;
}
}
//查看账户信息
private String getUserInfo(){
synchronized (lock){
return userInfo;
}
}
}
2.3.2.2、细粒度锁
使用不同的锁保护不同的临界资源,叫细粒度锁,优点就是对不同临界资源的操作并行化,性能高,缺点就是容易产生死锁:
package com.lcy.thread.part41;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019/9/3 09:44
*/
public class Account {
//账户锁
private final Object balLock = new Object();
//用户信息锁
private final Object infoLock = new Object();
//账户余额
private Integer balance;
//账户信息
private String userInfo;
//存款
private void addBalance(Integer amt) {
synchronized (balLock) {
balance += amt;
}
}
//取款
private void subBalance(Integer amt){
synchronized (balLock){
if(balance > amt){
balance -= amt;
}
}
}
//设置账户信息
private void setUserInfo(String newUserInfo){
synchronized (infoLock){
userInfo = newUserInfo;
}
}
//查看账户信息
private String getUserInfo(){
synchronized (infoLock){
return userInfo;
}
}
}
3、并发编程中应该注意的三个问题
3.1、安全性问题
就是保证程序运行结果的正确性,上面导致并发问题的三个原因,就都是安全性问题。
导致安全性问题的原因就是数据竞争,也就是存在多个线程对同一数据进行读写的情况,这时你就要注意安全性问题。
3.2、活跃性问题
3.2.1、死锁
3.2.1.1、问题描述
假如现实生活中有这样一个场景,现在有两个人都要炒菜,却只有一口锅和一把铲子,A先拿了锅,B先拿了铲子,A在等B用完铲子才能炒菜,而B也在等A用完锅才能炒菜,A和B会一直等待下去,就发生了死锁。
一组相互竞争资源的线程,由于相互等待对方释放自己执行下一步所需的临界资源,导致永久阻塞的现象称为死锁。
3.2.1.2、解决方案------预防死锁,破坏死锁条件
3.2.1.2.1、占用且等待条件
线程已经取得了一个共享资源,在对下一个被占用的共享资源发出申请时被阻塞,此时该线程并不释放已经取得的共享资源
破坏占用且等待条件很简单,就是一次性申请所有资源。对应到做饭的情景就是,锅和铲子都放在厨房,只有一个人能进厨房
package com.lcy.thread.part41;
import java.util.ArrayList;
import java.util.List;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019/9/3 17:21
*/
public class Kitchen {
private List<Object> kitchen = new ArrayList<>();
private Object pot = new Object();
private Object spatula = new Object();
synchronized List<Object> getTool(){
if(kitchen.contains(pot) || kitchen.contains(spatula)){
return null;
}
kitchen.add(pot);
kitchen.add(spatula);
return kitchen;
}
synchronized void backTool(Object pot,Object spatula){
kitchen.remove(pot);
kitchen.remove(spatula);
}
}
3.2.1.2.2、不可抢占条件
线程取得的共享资源不能被其他线程释放
破坏不可抢占条件需要用到java并发包里的相关类,我们到时候再说。对应到做饭的情景就是,一个人拿了锅再去拿铲子,如果铲子拿不到锅也不要了。
3.2.1.2.3、循环等待条件
当前线程会占用下一个线程的至少一种资源
破坏循环等待条件可以把资源排序,规定线程从小到大申请资源。对应到做饭的情景就是,规定做饭必须先拿铲子再拿锅。
3.2.2、活锁
3.2.2.1、问题描述
现实生活中可能会有这样的情况,两个人面对面走,快要撞上的时候,同时互相谦让走到另一条道上,结果还是过不去,如此反复。。。
线程之间并没有阻塞,但就是无法满足继续执行下去的条件,一直循环尝试->失败->尝试->失败的操作,这种情况称为活锁。
3.2.2.2、解决方案
失败之后设置一个随机等待时间再尝试
3.2.3、饥饿
3.2.3.1、问题描述
大家都知道,过马路要等红绿灯,如果有一天有个路口的红绿灯突然坏了,一边一直是绿灯,另一边一直是红灯,等红灯的人和车就要一直等一直等(如果都遵纪守法的话)。
线程因执行优先级较低或永久等待,无法得到cpu的运行时间块,而无法继续执行下去的状态称为饥饿
3.2.3.2、解决方案
保证资源分配的公平性,使用基于先来后到原则的公平锁,在java并发包里有相关类,我们后面会讲到
4、线程的生命周期
4.1、线程的生命周期
(1)初始化状态(NEW)
new Thread();JVM仅仅为其分配内存
(2)可运行状态(RUNNABLE)
Thread.start();表示线程已经可以运行了,但是什么时候运行取决于JVM线程调度器的调度
(3)运行状态(RUNNING)
线程获得CPU时间块,执行方法体
(4)阻塞状态(BLOCKED)
线程在等待获取共享资源的锁的时候
(5)无时间限制等待状态(WAITING)
在线程获取锁的时候,主动调用wait()等方法,会进入等待被唤醒的状态,并释放对应的锁
(6)有时间限制的等待状态(TIMED_WAITING)
在线程获取锁的时候,主动调用wait(long millis)等方法,会进入等待被唤醒的状态,并释放对应的锁,如果一定时间内没有被唤醒,则自己主动苏醒。
(7)终止状态(TERMINATED)
线程执行完毕或异常终止
4.2、wait()、notify()、notifyAll()的使用方法
我们以最简单的生产者消费者模型为背景来介绍,下面的例子主要就是两个线程对一个数的增减
生产者Producer,抢到锁后,数小于等于0就执行+5然后唤醒等待线程,sychronized结束才会释放锁;大于0就不执行:
package com.lcy.thread.part08;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019/9/10 09:29
*/
public class Producer implements Runnable {
private Test test;
public Producer(Test test) {
this.test = test;
}
@Override
public void run() {
String threadName = Thread.currentThread().getName();
while (true) {
synchronized (test) {
if (test.i <= 0) {
test.i += 5;
System.out.println(threadName +"生产...剩余" + test.i);
System.out.println(threadName +"去唤醒...");
test.notify();
}
}
}
}
}
消费者Consumer,抢到锁后,小于等于0就释放锁等待被唤醒;大于0就执行-1:
package com.lcy.thread.part08;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019/9/10 09:29
*/
public class Consumer implements Runnable {
private Test test;
public Consumer(Test test) {
this.test = test;
}
@Override
public void run() {
String threadName = Thread.currentThread().getName();
while (true) {
synchronized (test) {
if (test.i <= 0) {
System.out.println(threadName +"等待...");
try {
test.wait();
System.out.println(threadName +"醒了...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
test.i--;
System.out.println(threadName +"消费...剩余" + test.i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
测试类:
package com.lcy.thread.part08;
/**
* 功能描述:
*
* @author liuchaoyong
* @version 1.0
* @date 2019/9/10 09:35
*/
public class Test {
public int i = 5;
public static void main(String[] args) {
Test test = new Test();
Thread thread = new Thread(new Consumer(test));
Thread thread1 = new Thread(new Producer(test));
thread.start();
thread1.start();
}
}
其实要注意的只有一点,就是调用某个对象的wait()、notifyAll()的线程,一定要获取到该对象的锁才可以,否则会报java.lang.IllegalMonitorStateException异常