Java多线程安全

一、 案例引入

线程安全是多线程中最重要最复杂的部分。可能同一份代码在单线程的环境下执行是正确的,但在多线程环境中就不一定了。

示例:

public class Test4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 10000; i++){
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 10000; i++){
                count++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

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

在逻辑上,count应该自增了2w次,最终count的值应该为2w,然而结果却不是2w,而且几乎每次运行的结果都不相同

解释:

这是因为count++ 操作并不是原子的,本质上是分成三步的

1、load 把数据从内存中读到cpu寄存器中

2、add 把寄存器中的数据进行+1

3、save 把寄存器中的数据,保存到内存中。

如果是多个线程执行的话,由于线程之间的调度顺序是随机的,并不确定,就会导致出现问题

如:当第一个线程正在进行第一个操作的load的时候,第二个线程已经完成了第二、三、四的操作,此时第一个线程再进行第一个操作的add的时候,从寄存器中读取到的数据是0,而非3,因此就会出现错误。

总结:

产生线程安全问题的原因:

1、操作系统中,线程的调度顺序是随机的(抢占式执行)

2、不同线程,最对同一个变量进行修改

3、修改操作,不是原子的,即某个操作必须一起全部完成。

4、内存可见性问题

5、指令重排序问题


那要如何保证代码一定准确呢?答案是加锁

synchronized(对象名){

}

注意:

() 中需要表示一个用来加锁的对象,这个对象是啥不重要重要的是通过这个对象来区分两个线程是否在竞争同一个锁。如果两个线程是在针对同一个对象加锁,就会有锁竞争,如果不是针对同一对象加锁,就不会有锁竞争,而此时的并发程度最高,但是不能保证正确。

{}内的代码就是要执行的内容了。

当一个线程拿到了这把对象锁之后,另外一个线程就得阻塞,等待上一个线程释放锁,之后再进行竞争这把锁。

二、Synchronized的特性

2.1 修饰权限

synchronized不仅能修饰代码块,还可以修饰方法。

如下:

class Test{
      synchronized void fun(){
            
      }
}

//相当于

class Test{
	  void fun(){
          //使用this,表示对当前对象加锁
          synchronized(this){
              
          }
      }
}

//静态方法也是一样
class Test{
    synchronized static void fun(){

    }
}

//相当于

class Test{
    static void fun(){
        //这里Test.class为类对象
        synchronized(Test.class){
            
        }
    }
}

2.2 刷新内存

由于网上众说纷纭.......

2.3 可重入

所谓的可重入锁指的是一个线程中连续对某一个对象进行加锁,但不会出现死锁的现象,如果满足就是“可重入”。

举个例子

public class Demo01 {
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker){
                synchronized (locker){
                    
                }
            }
        });
    }
}

分析:如果没有可重入特性的话......

假设当在最外面的时候对locker对象加锁成功了,此时locker对象应该是"被锁定的状态",然后进行内层的加锁操作,发现此时locker已经是锁定的状态了,原则上,需要阻塞等待locker对象的锁被释放,才能进行第二次加锁,这样就形成了“死锁”,即第二次加锁操作需要等待第一次加锁操作释放锁,第一次加锁操作需要等待第二次加完锁后执行代码才能释放锁.......

但在Java中并不会出现这种情况,这是因为synchronized的可重入特性。当进行加锁操作的时候,会先记录一下是哪个线程获得了这个对象锁,后续这个线程再进行加锁的话,会检查是否已经持有了这个对象锁,如果有直接加锁成功。同理释放锁是在最外层的synchronized结束后,才释放锁(底层使用了计数器来管理,每当加锁一次,计数器+1,出了这个锁,计数器-1,如果为0了,则真正释放锁)。

三、 死锁

死锁可大致分为两类:一个线程一把锁,N个线程M把锁。

3.1 一个线程一把锁

这种情况也就是上面所说的情况,但在Java中synchronized是可重入锁,并不会产生,但在c++中,std::mutex可并不是可重入锁,就会出现死锁。

public class Demo01 {
    public static void main(String[] args) throws InterruptedException {
        
        Object locker = new Object();
        
        Thread t1 = new Thread(() -> {
            //并不会死锁~~
            synchronized (locker){
                synchronized (locker){
                    synchronized (locker){
                        synchronized (locker){
                            synchronized (locker){

                            }
                        }
                    }
                }
            }
        });
    }
}

3.2 N个线程M把锁

这个情境下,最经典的就是哲学家就餐问题。

描述如下

有5个哲学家在一张桌子前吃饭,在每个哲学家左手边放置一根筷子,哲学家拿起两根筷子才能吃饭,吃完饭才能把筷子放下。

如果某一时刻,某几个哲学家手快,拿起了两个筷子,那么这些哲学家就可以吃饭,吃完后放回筷子,然后竞争下一次。如果非常不巧,每个哲学家都抢到一根筷子,此时,所有人都没有两根筷子且此时所有人都持有一根筷子,每个哲学家都在等待别人将筷子放下,但是只有拿到两根筷子后才能放下筷子,这就陷入了死局。


死锁是比较严重的bug,会导致线程卡住,无法执行后续的代码。

如何避免死锁的产生呢?首先考虑产生的原因(4点)

1、互斥使用(锁的基本特性,无法改变)。即两个线程不能同时获得同一把对象锁,当一个线程获得这个对象锁的时候,另一个线程需要阻塞等待。

2、不可抢占(锁的基本特性,无法改变)。当一个线程获得这把对象锁后,另一个线程不能抢过来,只能等待释放这把锁才能去竞争。

3、请求保持(可通过调整代码结构避免)。一个线程可以拿到多把对象锁。即当一个线程获取到了锁1,再获取到了锁2,锁1不会立即释放。(吃着碗里的,看着锅里的)

4、循环等待(可通过调整代码结构避免)。如上述哲学家就餐问题,等待的依赖关系成环了。

要想出现死锁的情况,需要把上面的4个条件都占了,但其中的1和2是锁的基本特性不可避免,因此我们只需要针对3和4的情况。

对于条件3,避免编写“锁嵌套”,但这个有时候也无法避免。因此我们着重对条件4着手。

对于条件4,可以约定加锁的顺序,这样就可以避免循环等待。如:针对锁进行编号,加多把锁的时候,先加编号小的锁,再加编号大的锁。

哲学家就餐问题解决方案

我们规定,每个哲学都要遵守如下规定:选择左手和右手中编号较小的一根筷子,如果较小的那根筷子没了,那就等待出现编号小的筷子再进行竞争。这样优化以后,就不会出现僵持的现象了。

四、 volatile

4.1 案例引入

public class Demo01 {
    private static int Quit = 0;

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

            }
            System.out.println("退出成功");
        });

        Thread t2 = new Thread(() -> {
            System.out.println("请输入Quit的值:");
            Scanner sc = new Scanner(System.in);
            Quit = sc.nextInt();
        });

        t1.start();
        t2.start();
    }
}

在这段代码中,当我们通过线程2来修改Quit的值变为1时,此时线程1并没有退出输出“退出成功”,而是依然还在运行。

此处的问题就是”内存可见性“引起的,其实是编译器优化错了。

4.2 内存可见性

为什么会有内存可见性?

这是因为在计算机运算代码的时候,要经常访问数据,而这些数据存储在内存中,cpu使用这个变量的时候,就会把到内存中取出这个数,放到寄存器上,然后进行计算,但是读内存的速度相较于读寄存器慢了几千倍,如果要频繁的读内存的话会大大降低效率,因此编译器为了解决频繁读内存的问题,就对代码进行了优化,把一些本来要读取内存的操作优化成读取寄存器,从而使整体效率提升了。

对于上述案例,因为线程1的循环体内没有做任何事情,因此循环的速度非常快,但每一次循环的时候,都需要读取内存中Quit的值到寄存器中,编译器发现你老是读取这个值,然后这个值还一直没有修改,而每一次读都非常浪费时间,于是编译器就做了一个大胆的决定,不再从内存中读取了,而是直接从寄存器中拿值比较,于是后面的修改只是修改了内存中的值,实际比较的时候并没有改变。


这种情况下就得使用volatile来修饰Quit。在多线程环境下,编译器对于优化的判定不一定准确,此时就需要程序猿通过volatile关键字,告诉编译器不要进行优化。

五、 wait和notify

在多线程编程中,我们往往会涉及到多个线程间的配合调用。前面所提到join方法可以使线程阻塞,但得等到某个线程执行完后,才能解除阻塞,继续执行,而通过使用wait方法,可以手动阻塞某个线程,然后通过notify方法手动再让线程继续执行。

5.1 wait

wait方法的作用是:让当前调用的线程进入等待状态,直到其他线程调用notify方法。

wait方法是Object的方法,因此任何对象都有wait方法。

在执行wait方法的时候,会做3件事情。

  • 1、释放当前锁(如果当前线程没有进行加锁操作会报错)

  • 2、让当前线程进入阻塞状态

  • 3、当线程被唤醒的时候,尝试重新获取这把锁

5.2 notify

notify方法是用来唤醒等待的线程。有以下3点需要值得注意:

  • 1、notify方法需要在synchronized代码块中调用

  • 2、notify方法调用完后,当前线程不会立马释放对象锁,而是等到执行notify方法的线程执行完所有代码后才会去释放对象锁

  • 3、如果有对个线程等待,会随机挑选一个等待的线程唤醒。因此还提供了notifyAll方法,可以唤醒所有等待的线程。

5.3 线程饿死

假设现在有多个线程来竞争一把锁,第一次线程1抢到了这把锁,执行完代码后就释放了锁,然后进行下一次的锁竞争,恰巧第二、第三、第四...........第N次又抢到了这把锁(因为线程1已经在cpu上执行,没有调度的过程,更容易拿到锁),但是线程1每一次拿到锁又不干嘛,就光竞争,最后就有可能导致某些关键的线程一直拿不到锁。我们称这种情况为“线程饿死”。

针对这种情况,我们可以使用wait和notify来解决。让线程在某个条件下调用wait,把资源让出来,不参与后续竞争。

  • 21
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值