多线程-浅析线程安全
多线程-共享模型之管程
本文章是根据黑马JUC课程编写,记录的笔记
1 共享带来的问题
在平常开发中,很多时候都会遇到共享数据的问题,比如售票,库存。那么如何就会引出一个疑问,如何保证数据的安全性呢(就是数据共享的问题)!
- 下面一个小案例说明。
- 创建两个线程,对一个静态变量进行自增或者自减的操作,模仿买票售票,等等问题。
- 正常来说,他们最终的结果会是0
static Integer num = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
num++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
num--;
}
}, "t2");
t1.start();
t2.start();
t1.join();// 使其他线程等待t1执行完成
t2.join();// 使其他线程等待t2执行完成
log.info("num:{}", num);
}
执行多次,会发现,每次结果不相同,正常情况下,他们的执行结果会是0。
为什么结果不是0
首先我们得了num++ 会产生什么字节码
num++ getstatic i // 获取静态变量i的值 iconst_1 // 准备常量1 iadd // 自增 putstatic i // 将修改后的值存入静态变量i num-- getstatic i // 获取静态变量i的值 iconst_1 // 准备常量1 isub // 自减 putstatic i // 将修改后的值存入静态变量i
简单来说,就是当我们
t1线程刚对num进行自增操作时
,此时线程进行了程序上下文切换
(简单说就是cpu给他的时间用完了
,进入Rnnable 可运行状态
),然而下一次上下文切换,并没有给到t1,给到t2
。t1还未对常量池中的num赋值
,也就是说,t1这次的自增没有成功
,假如t2成功,num的值就发生了错乱。这也就是num不为0的原因
2 临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
例如,下面代码中的临界区:
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
3 解决共享带来的问题
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
我们采用synchronized对象锁的方式来解决,其他解决方案后续会有。
1 什么是synchronized
即俗称的【对象锁】,它采用互斥的方式让
同一 时刻至多只有一个线程能持有【对象锁】
,其它线程再想获取这个【对象锁】时就会阻塞
住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
注意
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
2 语法
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
3 解决
把上面案例的临界区用 synchronized 包裹
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock) {
num++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock) {
num++;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.info("num:{}", num);
}
4 思考
synchronized 实际是用对象锁保证了临界区内代码的
原子性
,临界区内的代码对外是不可分割的,不会被线程切换所打断。
- 请看下图红框位置,原本是我们线程2拥有cpu的使用权,在进行自增或者自减时,会产生四条字节码,对应下面四步操作,当执行到第三步,发生了上下文切换,因为我们使用synchronized ,使用并不会让1线程获取到锁(
会被阻塞
),然后继续上下文切换,直到切换到拥有锁的线程,也就是我们线程2
.
几个小提问
- 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
- 答:上次我们for循环是循环5000次,因为synchronized会保证临界区的原子性,也就是说会一次执行完5000次,然后其他线程才能执行。
- 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
- 答:都知道synchronized 是锁对象,他们俩是不同对象,所有还是会存在线程安全。
- 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
- 答:这种情况还是会存在线程安全,因为t2线程根本不需要去获取锁,所有并没有没阻塞。
4 进行改进
还是上面那个问题,平常开发中,我们更多的是对 对象的某个属性进行操作,所有我们采用
面向对象的方式,解决这个问题。
@Slf4j
public class C1_线程安全面向对象 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
// 创建两个线程
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.add();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.sub();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.info("num:{}", room.get());
}
}
// 被操作的对象
class Room{
int num = 0;
// 自加
public void add(){
synchronized (this){
num++;
}
}
// 自减
public void sub(){
synchronized (this){
num--;
}
}
// 获取
public int get(){
synchronized (this){
return num;
}
}
}
可以执行测试,效果是一样的。
5 变量的线程安全分析
成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
局部变量是线程安全的
但局部变量
引用的对象
则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
1 成员变量
先看一个成员变量的例子
class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2();
method3();
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
// 线程的数量
static final int THREAD_NUMBER = 2;
// 执行的次数
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
其中一种情况是,如果线程2 还未 add,线程1 remove 就会报错:
- 因为这两个线程是对同一个对象,进行修改或者赋值的操作。(一般都会采用局部变量)
示例图
2 局部变量
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2();
method3();
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
采用
局部变量
,就不会出现上面问题了。可以看到,每个线程都只是对自己的局部变量进行操作
。
- list 是局部变量,每个线程调用时会创建其不同实例,没有共享
- 而 method2 的参数是从 method1 中传递过来的,
- 与 method1 中引用同一个对象 method3 的参数分析与 method2 相同
示例图
方法访问修饰符带来的思考,
如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题? 情况1:有其它线程调用 method2 和 method3 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
// 形成新的临界区
method2(list);
method3(list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
list.remove(0);
}
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
执行,还是会存在
线程安全问题
,在for循环中,又会形成新的临界区,因为子类重写了method3,创建了线程,然而我们不能限制子类的行为。
- 从这个例子可以看出 private 或 final 提供【安全】的意义所在,请体会开闭原则中的【闭】
闭合原则:String
6 常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的,见后面分析
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
6.1 分析下面代码是否线程安全?
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
别灰心,你肯定打错了,我也打错了。给个提示,这些线程安全类的方法,单个是线程安全的,那么多个组合起立还是不是呢。
- 有没有可能发送这种情况,线程1执行完get,发生了上下文切换,然后线程2也执行完get,线程2有执行了put操作,再然后线程1又进行了put
6.2 不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的 有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?
- 本质上每次String执行 replace,substring,都会创建一个新的对象,并没有改变。所以不存在线程安全。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了