一、线程不安全的原因
对于多个线程,操作同一个共享数据(堆里边的对象,方法区中的数据,如静态变量)
- 如果都是读操作:没有赋值操作,只是获取值——没有安全问题
- 如果一个读,一个写
- 多个写(至少一个线程写操作,就会存在线程安全问题)
产生线程安全的原因:
- 原子性: 表示一组操作(可能是一行或多行代码),是不可拆分的最小执行单位,就表示这组操作是原子性的
某个线程对共享变量的多次操作,中间存在并发并行执行其他线程的对同一个共享变量的操作,就不具有原子性 一段代码具有原子性,类似打扫房间,没有打扫完,就不能进入其他的人
例子1: 例子2:
public class 线程安全问题_不安全 {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<10000;i++){
num++;
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<10000;i++){
num--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num);
}
}
实际结果并不为0,且每次运行结果都不一样 图解: 注意:一行Java代码,可能也不具有原子性 Java代码是一行,编译为class字节码,或由jvm把字节码翻译为机器码后,还是不是一行,不一定 比较典型的: ++,- -操作
线程作为系统调度cpu执行的最小单位 cpu执行时,使用内存数据还不够快 采取的方式,是把内存的数据加载到cpu寄存器中,再执行
- 可见性: 多个进程之间,使用自己的工作内存(cpu寄存器)来执行操作,互相之间是不可见的
- 有序性: jvm执行(翻译)字节码指令,cpu执行机器码指令,都可能对多行指令进行重排序优化执行效率
重排序: 1.有依赖关系的多行指令之间,不会重排序为不符合预期结果的顺序 2.单纯从一个线程视角看,人肉眼感知都是有序的(但实际存在重排序) 从多个线程视角看,代码都是乱序的(单个线程重排序,多个线程并发并行执行)
二、如何解决线程不安全的问题
涉及多个线程操作共享变量
1.synchronized关键字
语法: (1)也叫同步代码块 代码块:synchronized(对象){ ?… }
synchronized英文翻译过来是同步,前端ajax学过一个名词:异步(设置一个回调函数,等条件满足,由其他代码来执行回调函数) 同步: 使用对象来进行加锁,多个线程需要先申请锁(synchronized自动申请),代码块结束(自动释放锁):多个线程只能有一个线程获取到同一个对象的锁;注意:多个对象的锁,就可以是多个线程获取到
(2)实例方法:(也叫同步实例方法)
public class A{
public synchronized void doSomething(){
...
}
等同于
public void doSomething(){
synchronized(this){
...
}
}
A a=new A() 多个线程中,调用a.doSomething() this就是a同一个对象,就可以让多个线程同步
A a1=new A() A a2=new A() 线程1中:a1.doSomething():用a1加锁 线程2中:a2.doSomething():用a2加锁 不能同步,可以并发并行执行 (3)静态方法:(也叫同步静态方法)
public class A{
public static synchronized void doSomething(){
...
}
}
等同于
public class A{
public static void doSomething(){
synchronized(A.class){
...
}
}
}
多个线程调用a.doSomething就是同步的
加锁的对象,就是A这个类的类对象 JVM类加载:把一个class文件加载到java进程的内存,就是类加载,要做的事情: (1)class文件的代码加载到方法区 (2)在堆中生成一个类对象 Class< A > aClass = A.class 没有采取其他类加载手段的时候,多个线程中,A.class获取到的类对象都是同一个类对象
对于synchronized,底层原理是:
基于对象头加锁(对象有对象头这块区域,其中有一个状态的字段,标识是否加锁)的方式,一个对象,在一个时间,只能有一个线程获取到该对象的锁
关于对象头加锁:是JVM基于对象头加锁:monitor lock监视器锁 本质上会使用到操作系统mutex lock锁来实现(还有一些底层原理会在之后提到)
注意:synchronized加锁,是对对象头加锁,不是对代码来加锁
多个线程执行同一段synchronized的代码,是否具有同步的特性,要取决于是否是同一个对象
synchronized作用: 前提:多个线程申请同一个对象锁
- 互斥:同一个时间,只能有一个线程执行同步代码
- 刷新主存
- 可重入性:同一个线程可以多次申请成功同一个对象锁
图解:
2.volatile关键字
语法: 修饰一个变量的 作用: 保证可见性、有序性 注意: 不保证原子性——n++,n- -,所以即使使用volatile修饰n,也是线程不安全的 使用场景:
- 读操作:读操作本身是原子性,所以使用volatile这行语句就是线程安全
- 写操作:赋值操作是一个常量值,也可以保证线程安全(写到主存)。n++——n=n+1(这个操作不是原子性的,先读,修改,写回)
如:我们之前自定义标志位来中断线程,其实应该加上volatile修饰,才是线程安全(一个读,一个写常量值)
三、总结
关于使用多线程,需要考虑:
- 提高效率——多线程作用就是充分利用cpu资源,提高任务的执行效率
- 线程安全——多线程程序的底线
实际设计多线程代码:在满足多线程安全的前提下,尽可能的提高任务效率 所以,常见的设计方式:
单例设计模式
某个类的对象,只创建一个对象,所有使用的地方,都使用同一个对象 典型的:DataSource(数据库连接池)对一个数据库来说,只需要一个连接池对象(里边包含了多个数据库连接对象)
写法: (1)饿汉式: 静态变量=new 对象 (类加载时,就创建) (2)懒汉式: 静态变量=null 获取对象的方法中,if(静态变量=null) 静态变量=new 对象 (类加载时不创建,而是第一次调用方法获取对象时,创建)用于单线程,多线程中存在线程安全问题 (3)懒汉式(线程安全但效率低): synchronized修饰方法 (4)双重校验锁的写法:
语言表述:volatile修饰,synchronized加锁时,前后使用if判断 第一个情况:instance=null,多线程同时调用getInstance
volatile的原理: volatile保证可见性,有序性;在Java层面看,volatile是无锁操作的(在Java层面,多个线程对volatile修饰的变量进行读可以并发并行执行,和无锁的执行效率差不多)
volatile修饰的变量,cpu中,使用了缓存一致性协议来保证读取的都是最新的主存数据 如果其他地方修改了这个volatile修饰的变量,就会把cpu缓存中的变量置为无效,要操作这个变量就要从主存重新读取
假设volatile可以保证可见性,但不保证有序性,是否双重校验锁写法会有问题(了解)?
首先,变量 = new 对象();也会分为三条指令 (1)分配对象的内存空间 (2)实例化对象——存放在内存空间中(new操作,执行构造方法) (3)赋值给变量(=)
在这个过程中,可能重排序为132: 分配内存空间,对象还没有实例化完成,就可以赋值给instance 引用指向这块内存区域,此时,对象还没有实例化完成,重排序只保证本线程调用对象.属性/实例方法时,是实例化完成,不保证其他线程 此时instance指向了一块未实例化完成的内存 线程B执行第一个if判断 instance==null不满足,执行return 线程B拿到instance未完成实例化的对象,使用它的属性/方法,就会出错
使用volatile,保证有序性之后: 确保线程A,不管123啥顺序,但保证线程B使用这个变量的时候,线程A的123(这里不管是不是重排序),但必须全部完成,再指向线程B的使用instance的代码(第1行) 保证多个线程,指令之间,有一定的顺序 cpu里边,基于volatile的变量操作,是有加锁机制(线程A,123全部执行完,写回主存,再执行其他线程对该变量的操作)
所以,常见的设计方式:
- 加锁细粒度化(加锁的代码行少一点,让部分代码行可以并发并行执行)
- 考虑线程安全:没有共享变量操作的代码,没有多线程安全问题;共享变量的读,使用volatile就行;共享变量的写,加锁
|