什么是线程安全?如何保证线程安全?

目录

一、引入线程安全 👇

二、 线程安全👇

1、线程安全概念 🔍

2、线程不安全的原因 🔍

抢占式执行(罪魁祸首,万恶之源)导致了线程之间的调度是“随机的”

多个线程修改同一个变量 

 修改操作,不是原子的(不可分割的最小单位) 

内存可见性,引起的线程不安全 

指令重排序,引起的线程不安全

三、解决之前的线程不安全问题👇

1、synchronized 关键字-监视器锁monitor lock  🔍

1)synchronized 的特性 

(1) 互斥

(2)刷新内存

(3) 可重入

2)synchronized 使用示例 

1) 直接修饰普通方法:

 2) 修饰静态方法:

3) 修饰代码块: 明确指定锁哪个对象.

2、volatile 关键字 (保证内存可见性) 🔍

 1)引入volatile

2)volatile不保证原子性

💡 总结:


一、引入线程安全 👇

执行以下代码:

package threading;
class Counter {
    public int count = 0;
    void increase() {
        count++;
    }
}

public class ThreadDemo23 {
    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

可以观察代码,我们对两个线程分别累加50000次,结果应该为100000,但是大家看运行结果并非如此,而且可以发现,每次运行的结果都不一样,这是什么原因呢? 

答案就是涉及到了线程安全问题

 

 

本质原因:线程在系统中的调度是无序的/随机的(抢占式执行的) 


二、 线程安全👇

1、线程安全概念 🔍

    如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。 

2、线程不安全的原因 🔍

  • 抢占式执行(罪魁祸首,万恶之源)导致了线程之间的调度是“随机的”

  • 多个线程修改同一个变量 

一个线程修改同一个变量=>安全

多个线程读取同一个变量=>安全

多个线程修改不同变量=> 安全 

  •  修改操作,不是原子的(不可分割的最小单位) 

什么是原子性🐶

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

那我们应该如何解决这个问题呢?🐶

是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进 不来了。这样就保证了这段代码的原子性了。 有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

一条 java 语句不一定是原子的,也不一定只是一条指令 比如刚才我们看到的 n++,其实是由三步操作组成的: 1. 从内存把数据读到 CPU 2. 进行数据更新 3. 把数据写回到 CPU

不保证原子性会给多线程带来什么问题🐶

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。 这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性, 也问题不大.

  • 内存可见性,引起的线程不安全 

          可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

  • 指令重排序,引起的线程不安全


三、解决之前的线程不安全问题👇

1、synchronized 关键字-监视器锁monitor lock  🔍

1)对文章初始代码进行修改:既可以保证 ++ 操作就是原子的,不受影响啦

可以将{ }视为厕所,进表示加锁,出表示解锁

void increase() {
        //锁有俩个核心操作,加锁和解锁
        //进入该代码块就会触发加锁,出了代码块,就会触发解锁
        synchronized (this) {
            count++;
        }
    }

 注意:此处的this指的就是counter对象

因此,在上述代码中,两个线程实在竞争同一个锁对象,就会产生锁竞争。

再执行上述代码:就是100000

疑惑为啥以上操作为什么可以解决线程安全问题呢? 🐶

1)synchronized 的特性 

(1) 互斥

      synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待.

进入 synchronized 修饰的代码块, 相当于 加锁

退出 synchronized 修饰的代码块, 相当于 解锁 

理解 "阻塞等待". 针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝 试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的 线程, 再来获取到这个锁.

注意: 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这 也就是操作系统线程调度的一部分工作.

假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则. 

(2)刷新内存

synchronized 的工作过程: 

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁 所以 synchronized 也能保证内存可见性. 
(3) 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题; 

理解 "把自己锁死" :一个线程没有释放锁, 然后又尝试再次加锁. 

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第 二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会死锁

当然,Java 中的 synchronized 是 可重入锁, 因此没有上面的问题

 示例:

static class Counter {
    public int count = 0;
    synchronized void increase() {
        count++;
   }
    synchronized void increase2() {
        increase();
   }
}

increase 和 increase2 两个方法都加了 synchronized,

此处的 synchronized 都是针对 this 当前对象加锁的.

在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释 放, 相当于连续加两次锁)

这个代码是完全没问题的. 因为 synchronized 是可重入锁.

注意:在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.

如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取 到锁, 并让计数器自增.

解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到) 

2)synchronized 使用示例 

 synchronized 本质上要修改指定对象的 "对象头". 从使用角度来看, synchronized 也势必要搭配一个具 体的对象来使用.

1) 直接修饰普通方法:

锁的 SynchronizedDemo 对象🐶

public class SynchronizedDemo {
    public synchronized void methond() {
   }
}
 2) 修饰静态方法:

锁的 SynchronizedDemo 类的对象🐶

public class SynchronizedDemo {
    public synchronized static void method() {
   }
}
3) 修饰代码块: 明确指定锁哪个对象.

锁当前对象🐶

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}

锁类对象🐶

类对象是啥:Counter.class

.java源代码文件,javac =>.class(二进制字节码文件),JVM就可以执行.class文件了,类对象就可以表示这个.class文件的内容~~(描述了类的方方面面的详细信息,比如诶的名字,类的属性,类的方法,)

public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
       }
   }
}

2、volatile 关键字 (保证内存可见性)🔍

 1)引入volatile

所谓内存可见性,就是多线程环境下,编译器对于代码优化,产生了误判,从而引起了bug,进一步导致了代码的bug

因此我们可以加上 volatile(让编译器对这个场景暂停优化) , 强制读写内存. 速度是慢了, 但是数据变的更准确了.每次都是从内存中重新读取数据

观察以下代码:

package threading;

import java.util.Scanner;

public class ThreadDemo24 {
    public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {

            }
            System.out.println("循环结束,t1结束");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数");
            //t2通过控制台输入一个整数,yidanyonghushurule非0的值,此时t1的循环就会立即结束,从而t1线程就会退出
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 我们预期的效果应该是输入一个非零的数,线程t1就会停止,但实际上仍然在执行,处在RUNNABLE状态

 为什么会出现以上问题呢?内存可见性的锅!!!

让我们分析一下代码:

直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度 非常快, 但是可能出现数据不一致的情况.

 

volatile public static int flag = 0;

加上volatile关键字,此时编译器就可以保证每次都是重新从内存读取flag变量的值,

此时t2修改flag,t1就可以立即感知到了,t1就可以正确退了

2)volatile不保证原子性

这个是最初的演示线程安全的代码.

给 increase 方法去掉 synchronized

给 count 加上 volatile 关键字.

static class Counter {
    volatile public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(counter.count);
}

此时可以看到, 最终 count 的值仍然无法保证是 100000 

💡 总结:
  • volatile不保证原子性
  • volatile也能禁止指令重排序
  • volatile 适用一个线程读,一个线程写的情况
  • synchronized则是多个线程写 
  • volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性(也能保证内存可见性), volatile 保证的是内存可见 性
static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (true) {
            synchronized (counter) {
  if (counter.flag != 0) {
                    break;
               }
           }
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}

上面代码:

去掉 flag 的 volatile

给 t1 的循环内部加上 synchronized, 并借助 counter 对象加锁. 

运行结果是可以正常结束的

因此 synchronized是可以保证内存可见性的

 

 补充: 指令重排序,也是编译器优化的策略,调整了代码的执行顺序,让程序更高效,前提也是保证整体逻辑不变

 💡 总结:(面试题)

线程不安全的原因:

【根本原因】==操作系统上的线程是“抢占式执行”的,线程调度是随机的,==这是线程不安全的一个主要原因。随机调度会导致在多线程环境下,程序的执行顺序不确定,程序员必须确保无论哪种执行顺序,代码都能正常运行。
【代码结构】共享资源:多个线程同时访问并修改共享的数据或资源。当多个线程同时访问和修改共享资源时容易引发竞态条件和数据不一致的问题。
①一个线程修改一个变量是安全的
②多个线程修改一个变量是不安全的
③多个线程修改不同变量是安全的
【直接原因】多线程操作不是“原子的”。多线程操作中的原子性指的是一个操作是不可中断的,要么全部执行完成,要么都不执行,不能被其他线程干扰。这对于并发编程非常重要,因为如果一个操作在执行过程中被中断,可能导致数据不一致或者其他意外情况发生。(在上述多线程操作中,count++操作不是“原子的”,而是由多个CPU指令组成的,一个线程执行这些指令时,可能会在执行过程中被抢占,从而给其他线程“可乘之机”。要保证原子性操作,每个CPU指令都应该是“原子的”,即要么完全执行,要么完全不执行。)
内存可见性问题:在多线程环境下调用不可重入的函数(即不支持多线程调用的函数),可能导致数据混乱或程序崩溃。
指令重排序问题:在多线程环境下,由于编译器或处理器对指令进行重排序优化,可能导致预期之外的程序行为。

 

线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。
线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。


                        

线程安全是指在多线程环境下,当多个线程同时共享一个全局变量或静态变量进行写操作时,可能会发生数据冲突问题。而做读操作不会引发线程安全问题。为了保证线程安全,可以采用加锁机制,使每次执行的结果和单线程执行的结果一样,避免意外结果的出现。线程不安全则指在没有提供加锁机制保护的情况下,多个线程先后更改数据,导致所得到的数据是脏数据。 保证线程安全的方法有多种。其中一种方法是使用同步机制,例如使用synchronized关键字来保护共享数据的访问。在使用synchronized关键字修饰的代码块或方法中,同一时间只能有一个线程访问,其他线程需要等待。这样可以避免多个线程同时修改共享数据导致的数据错误。另外一种方法是使用原子操作类,例如使用AtomicInteger来保证对整数类型的数据的原子操作。这样可以避免多个线程同时对同一变量进行修改而导致的数据不一致问题。还可以使用锁机制,例如使用Lock接口和ReentrantLock类来控制对共享数据的访问,使用读写锁来实现读写分离的并发控制等。这些方法都可以保证多个线程在访问共享数据时的线程安全性。 总之,线程安全是在多线程环境下保证共享数据的正确访问的一种机制,可以通过使用同步机制、原子操作类和锁机制等方法来保证线程安全。这样可以避免多个线程同时对共享数据进行修改而导致的数据错误。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* [什么是线程安全问题 及怎么解决线程安全问题](https://blog.csdn.net/weixin_43464372/article/details/108233648)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *3* [什么是线程安全?如何保证线程安全?](https://blog.csdn.net/q669239799/article/details/90614077)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值