🎈一.观察多线程下++操作
首先我们抛出一个问题,我们利用两个线程对一个变量进行自增操作,两个线程各加5w,最后预期得到10w的结果.但是,我们能得到这样的结果吗?我们可以利用代码来验证一下.
class Counter{
int count=0;
public void add(){
count++;
}
}
public class ThreadDemo9 {
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
for (int i = 0; i <50000; i++) {
counter.add();
}
});
Thread t2=new Thread(()->{
for (int i = 0; i <50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
我们再来看一看,运行多次会出现什么样的结果
根据结果的显示,很显然没有达到我们想要的结果.同学们可以试想一下这是为什么呢?原因是多个线程对同一变量进行修改操作,这样就造成了线程安全问题.
如果在多线程环境下代码运行的结果是符合我们预期的,就是在单线程环境应该的结果,则说明这个是线程安全的.
🧨🧨🧨二.为什么会造成线程安全呢
2.1) 线程的执行是抢占式执行的,线程的调度充满随机性(罪魁祸首,而且还改变不了)
2.2)多个线程对同一个变量进行修改(这个可以通过调节代码,可以解决)
只是多个线程对同一个变量进行修改会造成线程安全
多个线程对同一个变量进行读操作不会造成线程安全
多个线程对同一个变量进行写操作不会造成线程安全
单个线程对变量的操作也不会造成线程安全
2.3)针对变量的操作不是原子的.
什么是原子呢? 在数据库中我们也提到了原子性.就是一组操作(一行或者多行代码)是不可以拆分的最小执行单位,这就表示这组操作具有原子性.
正如我们上面的那个count++的例子,在CPU执行的时候,它可能是被细分成了三个不步骤:
- 把内存的数据读到CPU中
- 然后进行更新数据
- 把更新之后的数据重新放入内存中.
上面的这些操作,是不会造成线程安全的,但是大家都知道,CPU的调度是随机的,不可能每次都是这样,还会出现一些别的情况.
如果CPU是这样调度的,执行完两个这两个线程,结果就得不到我们想要的结果.
2.4)内存可见性,一个频繁读,一个频繁写
可见性:指的是一个线程对共享变量值的修改,能够及时的被其他线程看到.
首先我们来看一端代码:
public class ThreadDemo10 {
private static int flag = 0;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while(flag == 0){
}
System.out.println("t线程执行完毕");
});
t.start();
Scanner sc = new Scanner(System.in);
flag = sc.nextInt();
System.out.println("main线程执行完毕");
}
}
按照我们的想法,这里应该当我们输入一个不为0的时候,t 线程就不进入循环直接走到下面执行完,但是结果却并非如此.一直都没有结束.
出现这样的原因是为什么呢?这是由于编译器优化造成的原因,由于访问CPU寄存器的速度远远大于访问内存,鉴于这个情况,编译器就做了一些优化,把数据读取优化掉了,因为每次读到的数据都是一样的,然后只执行比较操作.以至于我们改值之后,对应的 t 线程感知不到.
???为什么要保持内存可见性呢
保证每次获取的值都是内存中最新的值
2.5) 指令重排序
指令重排序也是编译器优化造成线程不安全
???重排序怎么理解呢
比如:我们要执行的操作是这样的
1.去前台拿u盘
2.去教室写作业
3.去前台取快递
这个如果在单线程的情况下,编译器会对其进行一些优化,执行顺序变为1->3->2执行,这是没有问题的,可以让我们少跑一趟,提高执行效率.
但是在多线程的情况下,代码执行复杂度更高,编译器如果再进行重排序的话,很容易导致逻辑和以前的逻辑不一样.
✨✨✨三.如何解决线程不安全问题
3.1 使用synchronized加锁
synchronized有互斥性,当某个线程执行到某个对象的synchronized中时,如果其他线程也执行到同一个对象synchronized就会阻塞等待.
进入synchronized修饰的代码块,就相当于加锁
出synchronized修饰的代码块,就相当于解锁
注意:如果多个线程尝试对同一个锁对象加锁,此时就会产生锁竞争,针对不同的锁对象加锁,就不会产生锁竞争.
我们可以把这个理解为上厕所(一个厕所),如果没人在厕所,就显示无人,那么就可以直接使用,如果显示有人,那么就要在外面等着,等着这个人出来,才可以进去.这就是等待着锁释放,才可以加锁
但是如果有一排的厕所呢,就不会产生都阻塞在外面的情况
加锁的语法格式:
- 直接修饰普通方法
public class SynchronizedDemo{
public synchronized void method(){
//......
}
}
- 修饰静态方法
public class SynchronizedDemo{
public synchronized static void method(){
//......
}
}
- 修饰代码块
//修饰当前对象
public class SynchronizedDemo{
public void method(){
synchronized(this){
}
}
}
//修饰类对象
public class SynchronizedDemo{
public void method(){
synchronized(SynchronizedDemo.class){
}
}
}
知道了加锁的写法之后,我们可以尝试把第一个例子拿出来修改修改,加锁让线程安全.
//这是对普通方法进行加锁
class Counter{
int count=0;
public synchronized void add(){
count++;
}
}
public class ThreadDemo9 {
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
for (int i = 0; i <50000; i++) {
counter.add();
}
});
Thread t2=new Thread(()->{
for (int i = 0; i <50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
//锁当前对象
class Counter{
int count=0;
public void add(){
synchronized (this){
count++;
}
}
}
public class ThreadDemo9 {
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
for (int i = 0; i <50000; i++) {
counter.add();
}
});
Thread t2=new Thread(()->{
for (int i = 0; i <50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
用了两种方法进行了加锁,得到的结果都是我们预期的结果,说明线程是安全的.
使用synchronized加锁操作,保证原子性,也能保证内存可见性
3.2 volatile关键字
volatile主要用于修饰变量的,它的作用是保证内存可见性,有序性.它是不能保证原子性的.
对于我们刚刚讲到的内存可见性的代码,我们进行改进
public class ThreadDemo10 {
volatile private static int flag = 0;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while(flag == 0){
}
System.out.println("t线程执行完毕");
});
t.start();
Scanner sc = new Scanner(System.in);
flag = sc.nextInt();
System.out.println("main线程执行完毕");
}
}
当我们使用了volatile关键字对变量进行了修饰,此时该变量就会禁止编译器进行优化,能够保证每次都从内存中读取数据
volatile除了保证可见性,还能禁止指令重排序.
以上是我对线程安全的理解,如有错误望指出