1、什么是并发编程
并发编程是为了提高程序的执行速度,在宏观上市的多个任务同时执行,则需要启动多个线程,但事实启动多个线程之后,不管针对单核cpu还是多核cpu线程进行上下文切换,cpu通过给每一个线程分配时间片,只有拿到时间片的线程才可以执行,通常时间片很短,所以才感觉到多个线程在并行操作,存在线程不安全性。一个正确执行的并发程序,满足并发编程的三大特性,原子性、可见性、有序性。
2、并发编程的特性
(1)原子性
所谓原子性是指一次或者多次操作中,要么所有的操作全部执行要么所有的操作都不执行,原子操作是不可分割的操作,一个原子操作中间是不会被其他线程打断的
int a = 10; // 10赋值给线程工作内存中的变量a 原子操作
a++; // 拿a 进行a+1 赋值a 非原子操作
int b = a; // 拿a b=a 非原子操作
a = a+1; // 拿a 进行a+1 赋值a 非原子操作
(2)可见性
如果在一个线程对变量做了修改,那么另外的线程立即可以看到修改后的最新值
假如有几个线程同时需要访问主内存中的共享变量,此时每一个线程都会拥有一个私有的工作内存,里面会有这个共享变量的副本,如果这个线程对该变量的访问结束会将修改后的结果刷新到主内存中,而如果该线程被打断没有完成对变量的访问,那么其他线程将不会看到修改一半的结果,而可见性就是保证其他线程可以立即看到修改后的最新结果。我们可以使用
volatile关键字
实现变量的可见性。
public class TestDemo {
private volatile static int initValue = 0; //1
private final static int MAX = 5; //2
public static void main(String[] args) {
new Thread("reader") {
//initValue副本
@Override
public void run() {
int localValue = initValue;
while (localValue < MAX) {
if (initValue != localValue) {
System.out.println("The initValue is updated to " + initValue);
localValue = initValue;
}
}
}
}.start();
new Thread("updater") {
@Override
public void run() {
//initValue副本
int localValue = initValue;
while (localValue < MAX) {
System.out.println("The initValue is changed " + (++localValue));
initValue = localValue;
//短暂休眠,目的为了让reader线程立即获取最新的initValue,输出
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
}
}
执行结果:
The initValue is changed 1
The initValue is updated to 1
The initValue is changed 2
The initValue is updated to 2
The initValue is changed 3
The initValue is updated to 3
The initValue is changed 4
The initValue is updated to 4
The initValue is changed 5
The initValue is updated to 5
当我们initValue
变量没有加上volatile
关键字时,两个线程之间的initValue
变量不可见,原因就是它们只在自己的工作内存中可见,而主内存不可见,加上volatile
关键字后,两个线程均可见。
(3)有序性
有序性指的程序代码在执行过程中的先后顺序,由于编译器或计算机的优化,导致代码的执行未必是开发者编写代码时的顺序
- 在单线程环境中,无论怎么重排序,都不会影响最终的结果
- 在多线程环境中,如果有序性得不到保证,最终结果也可能与预期不符,这种问题也可以通过
volatile
关键字进行避免
3、线程同步问题
(1)为什么需要同步
一块资源被多个线程同时操作,如果没有任何的同步操作,就会发生冲突,因为无法保证每一个线程的执行结束时机,所以就无法控制最终结果
(2)临界资源和临界区
- 临界资源:同一时刻只允许一个线程访问的资源,临界资源也是不可剥夺资源
- 临界区:访问临界资源的代码段。
- 临界区特点:提供线程独占式访问,也就是说若某一时刻有一个线程正在访问该代码段,其他线程想要访问,只有等待当前的线程离开该代码段才可以访问,这样保证了线程安全。
(3)线程安全
不考虑耗时和消耗,在单线程执行和多线程执行的情况下,最终得到的结果是相同的,那么称这样的操作是一个线程安全的操作。
(4)银行叫号
class TicketSystemTask implements Runnable{
private static final int max = 200; //最多叫到50号
private static int index = 1; //当前叫号值
@Override
public void run() {
while(index <= max){
System.out.println("当前办理业务为:"+Thread.currentThread().getName()+", 当前号码为:"+index++);
}
}
}
public class TestDemo2 {
public static void main(String[] args) {
TicketSystemTask task = new TicketSystemTask();
Thread t1 = new Thread(task, "1号柜台");
Thread t2 = new Thread(task, "2号柜台");
Thread t3 = new Thread(task, "3号柜台");
Thread t4 = new Thread(task, "4号柜台");
Thread t5 = new Thread(task, "5号柜台");
t1.start();
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
}
}
- 某个号码会有重复。
- 某个号码会被略过。
- 号码有可能超过当前最大值。
出现以上问题的原因为原先代码并没有满足并发编程的三大特征
4、Synchronized关键字
(1)Synchronized的使用
Synchronized同步代码块
- synchronized如果作用在代码块上,锁的是某一个obj的对象
public final Object obj = new Object();
public void sync(){
synchronized(obj){
//需要保证独占性的资源
}
}
Synchronized同步普通方法
- Synchronized加在普通方法上,锁的是对象实例
//修饰普通方法
public synchronized void test2() {
//doing
}
多个线程来竞争时,那个线程获取了该对象实例那个线程调用的方法才能继续执行
问题:加入存在两个线程,同时拥有该对象实例,一个线程调用test2方法,一个线程调用test4方法,两个方法可以同时执行嘛?
答:不能, test2和test4方法都是Synchronized修饰的普通方法,Synchronized加在普通方法上,锁的是对象实例一个对象实例时,一个线程获取,首先占有当前对象实例,然后调用该对象的方法,两个线程同时来要执行,哪一个线程先获取对象实例,则才能调用对应方法,另一个线程只能等占有的线程释放掉锁之后才能继续获取对象实例进行执行方法
例子:假如demo2线程抢先获取对象实例,则可以执行test2方法,同时demo4线程因为竞争对象实例synchronizeDemo失败,而需要等待demo2线程执行结束才能执行
Synchronized同步静态方法
- 如果Synchronized加在静态方法上,锁的是当前的class实例
//修饰静态方法
public synchronized static void test3() {
//doing
}
例题:实现两个线程,线程A输出5,4,3,2,1之后线程B再次输出5,4,3,2,1。
import java.util.concurrent.TimeUnit;
class MyRunnable implements Runnable {
public synchronized void test1() {
//获取MyThread类当前this引用的对象锁
int i = 5;
while (i >= 1) {
System.out.println(Thread.currentThread().getName() + ":" + i--);
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void test2() {
synchronized (MyRunnable.class) {
//获取当前MyRunnable类中class对象的对象锁
int i = 5;
while (i >= 1) {
System.out.println(Thread.currentThread().getName() + ":" + i--);
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
@Override
public void run() {
// test1();
test2();
}
}
public class TestDemo2 {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread threadA = new Thread(myRunnable, "线程A");
Thread threadB = new Thread(myRunnable, "线程B");
threadA.start();
threadB.start();
}
}
(2)Synchronized的原理
同步方法
常量池中多了ACC_SYNCHRONIZED标识符,标识当前的方法是一个同步方法,当前方法被调用,调用指令会检查ACC_SYNCHRONIZED标识符是否被设置,如果设置,线程会先驱获取monitor,获取成功之后才会去执行方法体,方法体执行完成之后才会释放monitor。
同步代码块
monitorenter
每一个对象都与一个monitor相关联,一个monitor的lock只能被一个下次在同一时间拥有,在一个线程尝试获取monitor的使用会发生以下事情:
- 如果monitor的entry count为0,意味着monitor的lock还没有被获取,某个线程获取之后会对entry count +1 ,从此该线程就剩这个monitor的所有者了。
- 如果一个已经拥有该monitor使用权的线程再次进入,会导致monitor的entry count +1。(可重入锁)
- 如果monitor已经被其他线程所拥有,其他线程尝试获取该monitor的所有权时,会被陷入阻塞,知到monitor的entry count为0,才能再次尝试去获取。
monitorexit
释放对monitor的使用权,要释放对某个对象的monitor的使用权前提是首先获取monitor的所有权,将monitor的entry count -1,如果entry count为0,那就表示该线程不再拥有该monitor的使用权。
(3)Synchronized的使用场景
场景1:两个线程同时访问同一个对象的同步方法
分析:两个线程来访问同一个对象锁,所以会相互等待,是线程安全的
结果:两个线程同时访问同一个对象的同步方法,是线程安全的。
场景2:两个线程同时访问两个对象的同步方法
分析:这是一种锁失效的情况,两个线程分别持有两个对象的同步方法,所以线程之间互不受限,加锁的目的是为了多个线程竞争同一把锁,而这个不存在多个线程竞争同一把锁,而是分别持有一把锁。
结果:两个线程同时访问两个对象的同步方法,是线程不安全的。
解决办法:只需要将方法用static修饰,这样就形成类锁(一个类只有一个class对象),多个实例竞争同一把类锁,可以做到线程安全。
场景3:两个线程同时访问(一个或者两个)对象的静态同步方法
分析:和上面场景2的锁实现的解决方案是同一个问题,一个类只有一个class对象。
结果:两个线程同时访问(一个或者两个)对象的静态同步方法,是线程安全的。
场景4:两个线程分别同时访问(一个或者两个)对象的同步方法和非同步方法
分析:
public class Condition implements Runnable {
@Override
public void run() {
if (Thread.currentThread().getName().equals("Thread-0")){
//执行同步方法
test1();
}
if (Thread.currentThread().getName().equals("Thread-1")) {
//执行非同步方法
test2();
}
}
//同步方法
public synchronized void test1() {
System.out.println("线程名:"+Thread.currentThread().getName()+" 线程开始");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程名:"+Thread.currentThread().getName()+" 线程结束");
}
//非同步方法
public void test2() {
System.out.println("线程名:"+Thread.currentThread().getName()+" 线程开始");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程名:"+Thread.currentThread().getName()+" 线程结束");
}
public static void main(String[] args) {
Condition condition = new Condition();
Thread t1 = new Thread(condition);
Thread t2 = new Thread(condition);
t1.start();
t2.start();
}
}
两个线程是并行执行的,所以是线程不安全的
场景5:两个线程访问同一个对象的同步方法,同步方法又调用另一个非同步方法
结果:不安全
场景6:两个线程同时访问同一个对象的不同的同步方法
结果:安全
场景7:两个线程同时访问静态synchronized和非静态synchronized方法
分析:两个线程锁的对象不同。
结果:不安全
场景8:同步方法抛出异常,JVM或自动释放锁
5、volatile关键字
JMM(JavaMemoryModel)模型
- 内存模型上,堆内存用来存储对象和基本数据类型的备份,称之为主内存,将栈内存中存储的变量的部分内存,称之为本地内存(工作内存)。
JMM模型工作特点:
- 1.Java线程对于所有的变量操作(读取,赋值)都是在自己的工作内存中进行的,线程是不直接读取主内存中的变量
- 2.不同线程无法直接访问对方的工作内存中的变量
- 3.线程间变量的传递主要是通过主内存来完成
主内存和本地内存具体的交互协议,即一个变量从主内存拷贝到本地内存,如果从本地内存同步到主内存的是有具体的交互操作:
主要分为8种操作:read(读取)
、load(载入)
、use(使用)
、assign(赋值)
、store(存储)
、write(写入)
、Lock(锁定)
、unlock(解锁)
(1)volatile特征
保证内存可见性
volatile修饰的变量(本地内存:java虚拟机栈/寄存器)不会被缓存在寄存器,变量在本地内存(虚拟机栈线程私有的空间)一旦被修改会立即回写至主内存,每一个线程访问主内存上的数据是最新的变量结果,如果已经有线程存在本地副本,即在回写时有效标志位会失效,从而使存储副本的线程能够进入主内存获取最新数据。
禁止指令重排序
Java内存模型不会对volatile指令进行重排序,从而保证对volatile变量的执行顺序,永远按照顺序书写的顺序执行。
重排序是语句happen-before
法则,法则之一规定“对volatile字段的写入操作happen-before
(优先)于每一个后续的同一个字段的读操作”。
注意:
- volatile字段只能够满足并发特征中的可见性、(有序性),不能保证原子性,也不能保证线程安全。
- volatile只能修饰变量,对基本类型的数据起作用
- volatile对对象不起作用,只能对对象的地址空间进行可见,即地址如果发生改变,其他线程能够立即感知,但是对象本身的属性发生改变,volatile是不能保证其他线程立即感知。
(2)volatile工作原理
《深入理解Java虚拟机》关于volatile描述:
“观察加入volatile关键字和没有加入volatile关键字所产生的汇编语言,加入了volatile关键字时,会多出一个Lock前缀指令”
Lock前缀指定实际是相当于一个内存屏障(内存栅栏),内存屏障存在的作用主要有3点:
- 它确保指令重排序的时候不会将其后面的指令排到内存屏障之前的位置,也不会将其前面的指令排到内存屏障之后。也就是执行到内存屏障这一指令时,在它前面的操作都已经全部执行完成
- 它会强制性的将本地内存(工作内存)中的数据立即写回主内存中
- 如果是写操作,它会立即导致其他线程对应的本地内存(工作内存)中的值无效
如果volatile当前修饰的是一个变量
- 变量值从主内存(在堆中)加载load到本地内存(虚拟机栈的栈帧中)
- 之后,线程对该变量的操作就不在和主内存联系,直接使用本地内存的副本数据(如果主内存中或副本的数据发生任何变化,在不互相联系的情况下,则导致主内存和副本数据不一致的的问题)
- 但是在volatile修饰的变量在某个线程中发生改变,基于volatile的的特征:立即将该变量的修改写回主内存,并且其他线程对应的本地内存(工作内存)中的值会立即失效
(3)volatile的使用场景
boolean标志位
例如:
统计:1秒内count++的次数
public class Count {
private static volatile boolean flag = true;//线程共享变量
public static void main(String[] args) {
Thread A = new Thread(new Runnable() {
@Override
public void run() {
int count = 0;
while (flag) {
count++;
}
System.out.println("count:" + count);
}
});
Thread B = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//到1秒钟,设置flag为false
flag = false;
System.out.println("1秒钟结束");
}
});
A.start();
B.start();
}
}
分析上面的代码,首先代码及变量都放在主内存变量flag为true,A线程执行要获取flag,通过交互操作将flag的副本拷贝到A线程的本地内存A,B线程执行睡眠1秒,修改flag值,即B线程从主内存中获取变量flag拷贝到本地内存B中,B线程将flag修改为false,注意:修改的是本地内存B中的flag副本。B线程的本地副本未及时写入主内存,或者主动写入主内存A线程本地副本未读取主内存最新的数据时,A线程的本地副本就一直是非最新值。
在演示代码中,线程共享变量flag在未加volatile关键字时,t2线程的修改,t1线程未能实时感知到,加了volatile关键字,可以让t2线程中对flag修改t1线程能够立即感知到
加入volatile之后, 在汇编层面在对应的汇编语句前加了“#Lock”
,当B线程修改flag变量操作时,
- 本地内存B将flag副本修改为最新值,并立即将最新值回写到主内存上,通过总线将A线程的flag副本的标识置为无效
- 当A线程来访问flag的本地副本时,先检测标志位为无效时,A线程会从主内存拷贝数据到本地内存副本上
单例模式双重检测锁
public class Singleton {
private volatile static Singleton instance;//方法区
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
扩展知识:
(1)happens-before原则: 8条
- 1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 2、锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- 3、volatile变量规则∶对一个变量的写操作先行发生于后面对这个变量的读操作;
- 4.传递规则∶如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 5、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
- 6、线程中断规则︰对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 7、线程终结规则︰线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束.Thread.isAliveO的返回值手段检测到线程已经终止执行
- 8、对象终结规则︰一个对象的初始化完成先行发生于他的finaiize)方法的开始
(2)重排序和禁止重排序介绍
- 重排序是编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
- volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象
(3)计算机底层内存模型
在计算机中,所有运算操作都由cpu的寄存器完成,涉及到数据的读取和写入就会与主存和cpu有关,cpu的处理速度和内存的访问速度差距太大,就有了cpu
cache 存在。
- cpu → cache → main memory
(4)cpu缓存一致性问题
比如i++
- 1、主内存中的数据复制到cache
- 2、cpu寄存器计算的时候从cache中读取和写入
- 3、cache刷新到主内存
单线程不会出现问题,多线程就会存在问题
为了解决缓存不一致的问题,主要通过两种方式:
- 总线加锁
- 缓存一致性协议 读取不做任何处理,写入发出信号通知其他cpu将变量的标志位置为无效状态,使得当前重排序过程发生改变,处理器会在计算之后对乱序执行的代码会进行重组,保证当前结果的准确性。