文章目录
- 一. 什么是线程安全
- 二. 线程不安全一个经典的例子
- 三. 对上述例子的理解
- 四. 出现线程不安全的原因
- 1. 线程在操作系统中是随机调度, 抢占式执行的
- 2. 当前代码中, 多个线程同时修改同一变量
- 3. 线程针对变量的修改操作, 不是"原子"的
- 4. 内存可见性问题, 引起线程不安全
- 5. 指令重排序, 引起线程不安全
- 五. 解决上述例子问题 --- 上锁
- 1. 锁
- 锁的主要操作
- 锁的主要特性
- 2. synchronized关键字
- 3. synchronized的其他写法
- 1.写在方法中
- 2. 修饰普通方法
- 3. 修饰static方法
一. 什么是线程安全
线程, 随机调度, 抢占式执行的, 这样的随机性, 就会使执行顺序, 产生变数, 可能会产生不同的结果, 如果这种结果认为不可接受, 则认为是bug
多线程代码, 引起了bug, 这样的问题, 就是"线程安全问题"
存在"线程安全问题"的代码, 就成为"线程不安全"
二. 线程不安全一个经典的例子
public class Demo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
上述代码, t1对count++50000次, t2对count++50000次, 按理来说count应该等于100000, 但是我们运行起来发现:
多运行几遍发现:
这就是非常严重的bug!!
这就是典型的多线程并发引起的问题
如果让俩个线程串行执行, 就没有任何问题的!!
三. 对上述例子的理解
CPU执行一个线程, 就是在执行指令, 再引入多线程随机调度, 那么事情就变得很复杂了
上述代码出现bug的原因在于:
- 这一行代码, 其实是3个cpu指令!!!
1)把内存count中的数值, 读取到cpu的寄存器中 => load
2)把寄存器中的值+1, 继续保存在寄存器中 =>add
3)将寄存器中计算后的值, 写回到内存count中 => save
(后面的名字使我们自己取得, 真实的cpu指令名字可能不是这个) - 多线程的执行, 是随机调度, 是抢占式的运行模式
某个线程执行指令的时候, 当他执行到任何一个指令的时候, 都有可能被其他的线程把CPU抢占走(操作系统把前一个线程调度走, 后一个线程上位)
结合上述两点, 实际并行开发的时候, 两个线程执行指令的相对顺序就可能会存在多种可能
不同的执行顺序, 得到的结果就可能会存在差异
可能顺序1:
这个顺序就是可以得到正确结果的顺序
可能顺序2:
这样的执行顺序就可能会出现问题
四. 出现线程不安全的原因
1. 线程在操作系统中是随机调度, 抢占式执行的
这是线程不安全的罪魁祸首
2. 当前代码中, 多个线程同时修改同一变量
注意关键词: 多个线程, 修改, 同一变量
一个线程修改同一变量
多个线程读取同一变量
多个线程修改不同变量
这些都是没有影响的, 不会出现bug
上述代码, 就是多个线程修改一个count变量
3. 线程针对变量的修改操作, 不是"原子"的
原子: 不可拆分的最小单位
如果某个代码, 对应到一个CPU指令, 就是原子
如果对应到多个, 就不是原子的
像count++这种操作, 就不是原子操作 => 对应三条指令
但是有的操作, 虽然也是修改, 但就是原子操作, 比如针对int / double 进行赋值操作, 就对应一个move指令
4. 内存可见性问题, 引起线程不安全
5. 指令重排序, 引起线程不安全
45后续介绍~~
五. 解决上述例子问题 — 上锁
解决线程安全问题, 最普适的方法, 就是通过一些操作, 把上述"非原子"操作, 打包成一个"原子"操作 ------ 上锁
1. 锁
锁, 本质上也是操作系统内核提供的功能, java(JVM)对这样系统api又进行了封装
锁的主要操作
关于锁, 主要操作两个方面:
1)加锁
t1加上锁后, t2也尝试加锁, 就会阻塞等待(都是系统内核控制, 在java中就可以看到BLOCKED状态)
2)解锁
直到t1解锁了之后, t2才可能加锁成功
锁的主要特性
锁的主要特性: 互斥(也叫锁竞争 / 锁冲突)
一个线程获取到锁之后, 另一个线程也尝试加这个锁, 就会阻塞等待
代码中, 可以创建多个锁, 只有多个线程竞争同一把锁, 才会产生互斥, 针对不同的锁, 则不会产生互斥
2. synchronized关键字
给count++加锁:
1.加锁前, 需要设定一个锁对象
java中, 随便拿一个对象, 都可以作为加锁的对象(这是java独特的设定, 其他语言, 只有极少数待定的对象可以用来加锁)
我们创建一个对象:
2. 使用synchronized(注意格式)
- synchronized后面跟着( ), ( )里面的内容就是**“锁对象”**
注意!!!
锁对象的用途只有一个, 有且只有一个, 就是用来区分两个线程是否是针对同一个对象加锁
如果是, 就会出现互斥 / 锁竞争 / 锁冲突, 就会引起阻塞等待
如果不是, 就不会出现锁竞争, 也不会阻塞等待
和对象具体是是啊类型, 和他里面有啥属性, 有啥方法…都没有关系!!
我们说"给对象加锁", 也不要误会, 锁对象只有区分是否是同一个锁的作用!!!
可以理解为: 每个对象只有一把锁, 给t1加锁后, t2就不能再用这个对象的锁了! - synchronized下面跟着{ }
当进入代码块{ }, 就是给上述锁对象进行加锁操作
当出了代码块{ }, 就是给上述锁对象进行解锁操作
对同一个对象加锁:
上述两个线程对同一个对象加锁, 那么就会发生互斥, 此时每次conut++操作都不会被打断, 从而真正可以加到100000
这样的阻塞, 就使t2的load出现在t1的save后, 强行的构造出了"串行执行"的效果
此时的运行结果:
对不同的对象加锁:
此时t1t2对不同的锁对象加锁, 此时就不会产生互斥, 他们之间还是"并发执行"的
运行结果为:
注意!!!
加锁不是"封装"!!!
t1加上object锁之后,
1)如果t2加的也是object锁, 那么t2是不能够"插队"的, 必须要等到t1解锁,
但是其他线程(没加object锁)是可以和t1抢占cpu的, 并不是将t1封装起来一起运行
2)如果t2加的不是object锁, 那么t2是可以抢占的!!
上述代码, 只有count++操作是互斥的, 是串行执行, 但是线程中的循环操作, 条件判断还是继续并行执行的, 也就是说, 当t1解锁之后, t2是不一定马上能上锁的, t1解锁后, 马上进入下一次循环, 又进行上锁操作, 此时t1 t2 是抢占调度的, 下一次是谁上锁是不一定的
3. synchronized的其他写法
我们将上述代码改写一下:
class Count{
private int count;
public void add(){
count++;
}
public int get(){
return count;
}
}
public class Demo14 {
public static void main(String[] args) throws InterruptedException {
Count count = new Count();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count.get());
}
}
同样也是多个线程对同一变量进行修改操作, 会产生bug
下面我们对add操作进行加锁:
已经有count对象了, 我们就可以直接用count对象进行加锁:
1.写在方法中
其实我们是对count++进行加锁, 所以可以将锁放在add方法中:
这里的this就指代count对象
2. 修饰普通方法
此时发现, 加锁的生命周期和方法的声生命周期是一样的, 这个时候, 就可以直接把synchronized写在方法上
synchronized修饰普通方法, 就相当于是对this加锁
3. 修饰static方法
synchronized也可以修饰static方法, 此时就相当于是对类对象加锁
相当于:
类对象:
类的属性信息, 包括类的名字, 继承自哪个类, 实现了哪些接口, 提供了哪些方法, 有哪些属性…等等类的全部信息
这些都是我们自己写的, 存在.java源代码中
经过javac编译后, .java => .class字节码文件(但是上述信息仍然存在, 只是变成了二进制)
经过java运行, 就会将.class文件的内容加载到内存中
给后续使用这个类, 提供基础
在内存中保存上述信息的对象, 就是类对象
在java代码中, 可以通过类名.class的方式拿到类对象
一个java进程中, 一个类, 只能有唯一的一个类对象
如果有多个线程调用func, 则这些线程一定会发生互斥!!!
(因为锁对象是类对象, 只有一个类对象)
如果多个线程调用add, 就不一定会发生互斥了
(锁对象是this, 指代的对象看你new了几个对象)