JavaEE之多线程编程:4. 线程安全(重点!!!)

一、观察线程不安全

下面我们来举个例子:
我们大家都知道,在单线程中,以下的代码100%是正确的。

public class Demo2 {
    // 此处定义一个 int 类型的变量
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        //对count变量进行自增5w次
        //线程t1
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t1.join();
        System.out.println("count:" + count);
    }
}

//执行结果:5w

但是,两个线程,并发的进行上述循环,此时逻辑可能就出现问题了。

public class Demo2 {
    // 此处定义一个 int 类型的变量
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        //对count变量进行自增5w次
        //线程t1
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        //线程t2
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();
        //如果没有这两个join,肯定不行!线程还没自增完,就开始打印了,很可能打印出来的count是0
        t1.join();
        t2.join();

        // 预期结果应该是10w
        System.out.println("count:" + count);
    }
}

//运行结果:和10w相差很大,且每次执行的结果都不一样

上述这样的情况就是非常典型的线程安全问题。这种情况就是bug!!
只要实际结果和预期的结果不符合,就一定是bug。

二、线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

三、线程不安全的原因

1. 关于线程不安全的解释

在这里插入图片描述

  1. 站在cpu的角度上,count++这个操作本质上是由cpu通过三个指令来实现的:
    ① load 把数据从内存,读到cpu寄存器中;
    ② add 把寄存器中的数据进行+1;
    ③ save 把寄存器中的数据,保存到内存中。
  2. 第二个角度:如果是多个线程执行上述代码,由于线程之间的调度顺序,是“随机”的,就会导致在有些调度顺序下,上述的逻辑就会出现问题。

因为是随机的,所以可能会有以下顺序:
在这里插入图片描述

除去这四种的顺序还有无数种的执行情况,因为可能存在t1执行1次,t2执行n次的情况。

结合上述讨论,就意识到了,在多线程程序中,最困难的一点是:线程的随机调度,使两个线程执行逻辑的先后顺序,存在诸多可能。我们必须要保证在所有可能的情况下,代码都是正确的!!

在上述这些排列顺序中,有的执行结果是正确的,有的则是存在问题的如:
【执行结果正确的情况】
在这里插入图片描述

【执行结果错误的情况】
在这里插入图片描述
上述顺序执行完毕之后,bug 就出现了!
两个线程,分别自增一次,预期应该是得到2,实际上只有1,这就相当于自增过程中,两个线程的结果是没有往上累加的。

由于我们也不知道,这这5w次自增的过程中,有多少次是按照正确的方式自增的,有多少次,是无法正确自增的,因此最终的结果,就是一个“随机”值,并且这个随机值,一定是小于10w的值!

【总结】产生线程安全问题的原因为以下6种:

1. 抢占式执行

操作系统中,线程的调度顺序是随机的(抢占式执行),罪魁祸首,万恶之源。

调度顺序是随机得,这个是系统内核里实现得,最初搞多任务操作系统的大佬,制定了“抢占式执行”大的基调,在这个基调下,想要做出调整是非常困难的,所以根据这个原因解决抢占式执行是行不通。

2. 修改共享数据

两个线程,针对一个变量进行修改。
① 一个线程针对一个变量修改,可以;
② 两个线程针对不同变量修改,可以;
③ 两个线程针对一个变量读取,可以。

想要解决此原因带来的线程安全问题,有些情况下,可以通过调整代码结构,规避上述问题,但是也有很多情况,调整不了。

3. 原子性

此处给定的count++ 就属于是 非原子的操作(先读,再修改)
类似的,如果一段逻辑中,需要根据一定的条件来决定是否修改,也是存在类似的问题。
(假设 count++是原子的,比如有个cpu指令,一次完成上述的三步)

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

【解决方式】
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
就是想办法让count++这里的三步走,成为“原子”的,即加锁,MySQL并发执行事务,隔离性。

4. 可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到。
当前代码不涉及。

5. 指令重排序问题

当前代码不涉及。

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

上述我们分析得到的结论是,通过“加锁”就能解决上述问题。

如何给Java种的代码加锁呢?
其中最常用的方法,就是synchronized,在synchronized使用的时候,要搭配一个代码块{};
进入{就会加锁},出了{}就会解锁,在已经加锁的状态中,另一个线程尝试同样加这个锁,就会产生“锁冲突/锁竞争”,后一个线程就会阻塞等待。一直等到前一个线程解锁为止。

【语法】

synchronized() {
}

其中:()中需要表示一个用来加锁的对象,这个对象是什么不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。
如果两个线程是在针对同一个对象加锁,就会有锁竞争;
如果不是针对同一个对象加锁,就不会有锁竞争,仍然是并发执行!

PS:可以把锁想象成一个girl,你向girl表白,把她追到手了,你就相当于给此girl加锁了。
如果另一个小哥,也尝试追同一个girl,他就得阻塞等待,等你俩分手,他才有机会。
但是这个小哥如果追的是其他的单身girl,就不会收到你这边的影响!

public class Demo2 {
    // 此处定义一个 int 类型的变量
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
    //定义锁对象
        Object locker = new Object();
        //对count变量进行自增5w次
        //线程t1
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }

            }
        });
        //线程t2
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //和线程t1使用的是同一个对象加锁
                synchronized (locker) {
					 count++;
                }
            }
        });

        t1.start();
        t2.start();
        //如果没有这两个join,肯定不行!线程还没自增完,就开始打印了,很可能打印出来的count是0
        t1.join();
        t2.join();

        // 预期结果应该是10w
        System.out.println("count:" + count);
    }
}

//运行结果
count:100000 //结果正确!

使用此种方式编写代码,两个线程的执行过程就会相互影响。相当于:
在这里插入图片描述
t2 线程由于锁的竞争,导致 lock 操作出现阻塞,阻塞到 t1 线程 unlock 之后,t2 的 lock 才算执行完。
阻塞避免了t2 的 load、add、save 和第一个线程操作出现穿插,形成这种“串行”执行的效果。此时线程安全问题,就迎刃而解了!!

【注意】
如果是针对不同的对象加锁,我们这两个锁操作,中间就不会有这样的阻塞等待,不会有锁竞争,仍然会出现互相穿插的情况,那么线程安全问题仍然存在。

 private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Object locker2 = new Object();
        //对count变量进行自增5w次
        //线程t1
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }

            }
        });
        //线程t2
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //和线程t1使用的是同一个对象加锁
                synchronized (locker2) {
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
        //如果没有这两个join,肯定不行!线程还没自增完,就开始打印了,很可能打印出来的count是0
        t1.join();
        t2.join();

        // 预期结果应该是10w
        System.out.println("count:" + count);//结果错误
    }

}

//运行结果
小于10w

五、synchronized 关键字(两个线程同时修改一个变量)

1. synchronized 的特性

(1)互斥

synchronized修饰的是一个代码块,同时会指定一个“锁对象” ,它会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待

  • 进入 synchronized 修饰的代码块, 相当于对该对象进行加锁
  • 退出 synchronized 修饰的代码块, 相当于对该对象进行解锁
synchronized void inchrease(锁对象) {//进入方法内部,相当于针对当前对象“加锁”
	count++;
}//执行方法完毕,相当于针对当前对象“解锁”

锁对象到底用哪个对象无所谓,对象是谁不重要,重要的是两个线程加锁的对象,是否为同一个对象!

这里的规则意义只有一个:
当两个线程同时尝试对一个对象加锁,此时就会出现“锁竞争”,一旦竞争出现,一个线程能够拿到锁,继续执行;一个线程拿不到锁,就会阻塞等待,等待前一个线程释放锁之后,它才会有机会拿到锁,继续执行。
这样的规则,本质上是把“并发执行”——>“串行执行”。此时就不会出现“穿插”的情况了。

synchronized 用的锁是存在对象头里的。
在这里插入图片描述
Java的一个对象,对应的内存空间中,除了你自己定义的一些属性以外,还有一些自带的属性,这个自带的属性,就是对象头。其中对象头中,其中就有属性表示当前对象是否已经加锁。
也可以粗略的理解成,每个对象在内存中存储的时候,都有一块内存表示当前的“锁定”状态。

(2)刷新内存

刷新内存(存疑,很多资料有的说有,有的说没有,此处了解一下)

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

所谓的可重入锁,指的是一个线程连续针对一把锁,加锁两次且不会出现死锁。满足这个要求,就是“可重入”,不满足就是“不可重入”。

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

下篇博客我将详细的解释死锁问题!这里我们简单介绍一下。

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

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待。直到第一次的锁被释放,才能获取到第二个锁。但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无
法进行解锁操作。这时候就会死锁
在这里插入图片描述
这样的锁称为“不可重入锁”。

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

【代码示例】

在下面的代码中:

  • increase 和 increase2 两个方法都加了 synchronized,此处的 synchronized 都是针对 this 当前对象锁的。
  • 在调用 increase2 的时候,先加了一次锁,执行到 increase 的时候, 又加了一次锁。(上个锁还没释放, 相当于连续加两次锁)

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

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

在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息。

  • 如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正式是自己,那么仍然可以继续获取到锁,并让计数器自增。
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁.。(才能被别的线程获取到)。

2. synchronized 使用示例

synchronized 除了修饰代码块之外,还可以修饰一个实例方法,或者修饰一个静态方法。

【实例方法】

class Counter {
    public int count;
    
	//实例方法1
    synchronized public void increase() { //使用this为锁对象
        count++;
    }
	//实例方法2
 public void increase2() { //这两种写发是等价的,可以理解为上述代码是这个的简化版本
        synchronized (this) {
            count++;
        }
    }
}

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        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);
    }

}


//运行结果:100000

其中,实例方法1和实例方法2是等价的,可理解为实例方法1为实例方法2的简化版本。

【静态方法】
如果是静态方法,相当于是针对类对象加锁。

	//静态方法1
	synchronized public static void increase3() {
        
    }
    //静态方法2
    public static void increase4() {
        synchronized (Counter.class) {
            
        }
    }

静态方法1和2也是等价的,相当于1是2的简化解法。

3. Java标准库中的线程安全类

Java 标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施。

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的,使用了一些锁机制来控制。

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

在这里插入图片描述
StringBuffer 的核心方法都带有synchronized .

还有的虽然没有加锁,,但是不涉及 “修改”,仍然是线程安全的:String。

4. 总结

synchronized使用规则上并不复杂,抓住一个原则:两个线程针对同一个对象加锁,就会产生锁竞争。

此外,可重入这个特征引出死锁,我们下篇博客详细介绍!

六、volatile 关键字(一个线程读,一个线程修改)

volatile涉及到 保证内存可见性 和 禁止指令重排序。

1. volatile 能保证内存可见性

计算机运行的程序/代码,经常要访问数据。这些依赖的数据,往往会存内存中。(定义一个变量,变量就是在内存中),CPU在使用这个变量的时候,就会把这个内存中的数据,先读出来,放到CPU的寄存器中,再参与运算(load)。

CPU读取内存的这个操作,其实非常慢!CPU在进行大部分操作,都很快,一旦操作到读/写内存,此时速度一下就降下来了。

【重要结论】内存可见性:
为了解决上述问题,提高效率,此时编译器,就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就可以提高整体程序的效率了。
但是!编译器也不是万能的,它对代码做出优化的判断可能会判断错误,进一步导致运行结果错误。

volatile 修饰的变量,能够保证“内存可见性”。
在这里插入图片描述
其中:
主内存,就是我们平常所说的内存;
工作内存,就是寄存器。

代码在写入 volatile 修饰的变量的时候,

  • 改变线程工作内存中volatile变量
  • 将改变后的副本的值从工作内存刷

代码在读取 volatile 修饰的变量的时候,

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

前面我们讨论内存可见性时说了,直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存),速度非常快,但是可能出现数据不一致的情况。
加上 volatile ,强制读写内存。速度是慢了,但是数据变的更准确了。

【代码示例】

在这个代码中:

  • 创建两个线程 t1 和 t2;
  • t1 中包含一个循环,这个循环以 isQuit == 0 为循环条件;
  • t2 中从键盘读入一个整数,并把这个整数赋值给 isQuit;
  • 预期当用户输入非 0 的值的时候,t1 线程结束。
 public static int isQuit = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
           while (isQuit == 0) {
               //循环里啥也没干
               //此时意味着这个循环,一秒钟就会执行很多次
           }
            System.out.println("t1 退出!");
        });
        t1.start();

        Thread t2 = new Thread(()->{
            System.out.println("请输入isQuit:");
            Scanner sc = new Scanner(System.in);
            //一旦用户输入的值,不为0,此时就会使 t1 线程执行结束。
            isQuit = sc.nextInt();
        });
        t2.start();

    }

//执行效果
//当用户输入非0值时,t1线程循环不会结束。(这显然是个bug)

在这里插入图片描述

此时代码预期效果:
用户输入非0值之后,t1线程要退出。但是实际上当我们真正输入1的时候,此时t1线程并没有结束!
通过jconsole也能看到,t1线程正在执行。RUNNABLE状态!
由于多线程引起的,也是线程安全问题!此时就是“内存可见性”情况引起的!

如果给 isQuit 加上volatile

 private volatile static int isQuit = 0;

//执行结果
//当用户输入非0值时,t1线程循环能够立即结束。

在这里插入图片描述

这里的内存可见性解释(了解):
isQuit == 0中:
① load 读取内存中 isQuit 的值到寄存器中;
② 通过 cmp 指令比较寄存器的值是否是0,决定是否需要循环。
由于这个循环,循环速度非常快,短时间内就会进行大量的循环,也就是进行大量的 load 和 cmp 的操作。
此时,编译器 JVM 就发现了,虽然进行了这么多次 load,但是load出来的结果都是一样的,并且 load 操作又非常的浪费时间,一次 load 花的时间相当于上万次 cmp 了。
所以编译器就做了一个大胆的决定!(编译器优化)只是第一次循环的时候读了内存,后续都不在读内存了,而是直接从寄存器中,取出 isQuit 的值了。
编译器优化是希望能够提高程序的效率,但是提高效率的前提是逻辑不变,此时由于修改了 isQuit 代码是另一个线程的操作,编译器没有正确的判定。它以为没人修改 isQuit,而做出了优化,引起了bug。
这个问题,就成为“内存可见性”问题。

volatile 就是解决方案!
在多线程环境下,编译器对于是否要进行这样的优化,判定不一定准,就需要程序猿通过volatile 关键字,告诉编译器,你不要优化!(优化是算的块了,但是算的不准!)

2. volatile 不保证原子性

volatile和synchronized都对线程安全起到一定的积极作用,但是各司其职,他们有着本质上的区别,synchronized 能够保证原子性,volatile 保证的是内存可见性,是不能保证原子性的。

如,上述count++代码,用volatile,他最后的结果也是不正确的,不是10w。

且 synchronized 也能保证内存可见性,有这种说法,但是存疑,这里要注意一下。

  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值