【JavaEE】多线程的synchronized、volatile 关键字、死锁以及wait 和 notify的手术刀剖析

一、synchronized关键字

1.synchronized的用法

  1. 把synchronized加到普通方法上
synchronized public void increase(){
        count++;
    }

在这里插入图片描述


  1. 把synchronzied加到代码块上
public void increase(){
        synchronized (this){
            count++;
        }
    }

在这里插入图片描述


  1. 把synchronized加到静态方法上

  这里所谓的“静态方法”,更严谨的叫法应该叫做“类方法”;而我们平时所说的“普通方法”,更严谨的教法其实是叫“实例方法”。

public static void func(){
        synchronized(Counter.class){
            
        }
    }

在这里插入图片描述


  synchronized关键字也叫做监控器锁(monitor lock),有时候的代码异常信息,可能会提到monitor这个词。


2.synchronized 的特性

(1)互斥

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

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

在这里插入图片描述
  上面提到过一下,synchronized用的锁是存在Java对象头里的。这个对象头如何理解呢?

在这里插入图片描述
在这里插入图片描述
  那阻塞等待又是什么意思呢?

  理解 “阻塞等待”:

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

  【注意】

  1. 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
  2. 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.

synchronized的底层是使用操作系统的mutex lock实现的.



(2)刷新内存

  synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

所以 synchronized 也能保证内存可见性,具体在后面介绍volatile的时候讲解。


(3)可重入

直观来说,同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入

  想理解什么是可重入,我们先来理解什么是死锁,这里举一个例子:在这里插入图片描述
  上面的这种情况就是一个死锁的例子了。

  在这里思考一个问题:如果我们的代码连续锁两次会怎么样?

//连续锁两次
synchronized public void increase(){
        synchronized (this){
            count++;
        }
    }

在这里插入图片描述
  实际上,这种代码在实际开发中,一不留神可能就会写出来,那么如果代码真的死锁了,程序猿修bug此不是修死了?
  于是,实现JVM的大佬们显然也注意到了这一点,就把synchronized实现成了可重入锁。对于可重入锁来说,上述的操作是不会导致死锁的。

  那么,可重入锁是如何作用的呢?

可重入锁的内部会记录当前锁被哪个线程占用的,同时也会记录一个“加锁次数”count。 当线程a针对锁进行第一次加锁的时候,显然能够加锁成功,那么锁内部就记录了当前占用着的是a,同时加锁次数count=1。 后续如果我们再a对锁进行加锁的时候,此时就不是针对加锁,而是单纯的把计数给自增,加锁次数变为2。 如果后续再解锁的时候,先把计数-1,当锁的计数减到0的时候,就真的解锁了

  可重入锁的意义是降低了程序猿的负担,也就是降低了使用成本,提高了开发效率。然而,也带来了代价,那就是我们的程序中需要有更多的开销,我们需要去维护锁属于哪个线程,并且需要加减计数,降低了运行效率。


二.死锁

1.死锁的场景

(1)一个线程一把锁,这个就是上面讲的例子,这里不再重复

(2)两个线程两把锁
  这里是如何导致死锁的呢?我们举一个例子来理解,两个外卖小哥互不相让,那么在这种情况下就会导致死锁了。

在这里插入图片描述
(3)N个线程,M把锁

  这里举一个经典的问题:哲学家就餐问题来理解。

在这里插入图片描述

  这个问题,其实跟上面第二种情况本质不变,只是情况变得更加复杂了,这里的筷子就表示着一把锁,哲学家就是一个线程,五个线程五把锁。


2.死锁的四个必要条件

  上面,我们只是说了死锁的几个场景,可是它为什么就是死锁呢?因为它们都满足以下死锁的四个必要条件。

  1. 互斥使用,一个锁被一个线程占用了之后,其他线程就占用不了(锁的本质,保证原子性)
  2. 不可抢占,一个锁被一个线程占用了之后,其他的线程不能把这个锁抢走(不能挖墙脚)
  3. 请求和保持,当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是该线程持有的
  4. 环路等待,等待关系成环了。A等B,B等C,C又在等A。

  当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。以上前三个特点是锁本身的特点,在实际开发当中,如果要避免死锁,还是得从第4个条件入手,也就是不让它进入环路等待。


3.如何破坏死锁

  刚刚说了,破坏死锁最好就是从第四点入手,因为前3个都属锁本身的特点。那么破坏死锁的关键其实很简单,就是两个字——规则。

在这里插入图片描述
  但是在实际开发中,很少会出现一个线程需要锁里再套锁这样的情况

synchronized(a){
	synchronized(b){
		synchronized(c){
		}
	}
}

  如果不嵌套使用锁,也就没那么容易死锁,也就是我们从实际使用上就规避了这种情况的产生。如果实在不得不使用这样的场景,那么一定要预定好加锁的顺序,所有的线程都按照a->b->c…这样的顺序加锁,避免出现环路等待。


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

  谈到线程安全,就不得不提以下Java标准库中的线程安全类。Java中有很多现成的类,有些类是线程安全的,有些是不安全的。在多线程下,如果使用线程不安全的类,就需要谨慎了,因为这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.

线程不安全线程安全
ArrayListVector (不推荐使用)
LinkedListHashTable (不推荐使用)
HashMapConcurrentHashMap
TreeMapStringBuffer
HashSetString(特殊的)
TreeSet
StringBuilder

  线程安全的类使用了一些锁机制来控制,也就是说它在一些关键的方法上面都有synchronized,有了这个操作,就可以保证在多线程环境下,修改同一个对象,不会发生什么差错。像线程不安全的类这些在单线程环境下使用没多大问题,但在多线程下就会有大问题。

  线程安全类里面有一个String类,这个是特殊的,因为这个东西没有synchronized,它属于安全类是因为它是不可变对象,没法在线程中修改同一个String,也就是说String虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的。

  【注意】
  不可变对象和常量/final之间没有必然联系,不可变对象的意思是没有提供public的修改属性操作,而String源代码中的final表示String不能被继承。


三、volatile 关键字

1.volatile 能保证内存可见性

  这个内存可见性上篇介绍多线程的文章详细介绍过。
【JavaEE】线程安全与线程不安全问题手术刀剖析

  volatile这个关键会禁止编译器优化,保证内存可见性。

在这里插入图片描述


2.JMM(Java内存模型)

  这个先介绍一下这个JMM,Java Memory Moder(Java内存模型)。这个东西是啥呢?其实这个东西就是把我们之前所讲的硬件结构,在Java中用专门的术语又重新抽象封装了一遍。比如:

在这里插入图片描述
  那为什么要这样做呢?因为Java作为一个跨平台的编程语言,要把硬件的细节封装起来,让程序猿感觉不到CPU,内存等硬件设备的存在。

  
  上面的哪个图还是比较粗糙一点,实际上,在我们的内存与存储之间还有一个缓冲空间,我们称为cache。

在这里插入图片描述


3. volatile 不保证原子性

  volatile只是保证可见性,不保证原子性。也就是说,volatile只是处理一个线程读一个线程写的情况,而synchronized都能处理。

  有同学看到这里可能会想,既然如此,那么到时候直接无脑用synchronized就可以。

  实际上,我们也不能无脑用,因为synchronized的使用是要付出代价的,代价就是一旦使用synchronized就很容易导致线程阻塞,一旦线程阻塞(放弃CPU),下次回到CPU,这个时间就不可控了,如果调度不回来,对应的任务执行时间,自然就会被拖慢。严重一点说,一旦使用了synchronized,这个代码大概率与“高性能”无缘了。

  而volatile不会导致线程阻塞。


四、wait 和 notify

1.wait 和 notify的使用

  wait 与notify是用来处理线程调度随机性的问题的,我们在程序运行的时候不喜欢随机性,更倾向于它们有一个固定的顺序来运行。因此,我们就可以使用wait和notify。

  wait和notify都是Object对象方法,调用wait方法的线程,就会陷入阻塞,阻塞到有其他线程通过notify方法来通知。

public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait前:");
        object.wait();
        System.out.println("wait后:");
    }

在这里插入图片描述
  为什么会发生这样的情况呢?

  是这样的,调用wait方法后,wait内部会做3件事:

  1. 先释放锁
  2. 等待其他线程通知
  3. 收到通知之后,重新获取锁,并继续往下执行

因此,想要使用wait和notify,就得搭配synchronized。

public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object){
            System.out.println("wait前:");
            object.wait();
            System.out.println("wait后:");
        }
    }

在这里插入图片描述

 private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            synchronized (locker){
                //1.进行wait
                System.out.println("wait之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();

        Thread.sleep(3000);

        Thread t2 = new Thread(()->{
            //2.进行notify
            synchronized (locker){
                System.out.println("notify之前");
                locker.notify();
                System.out.println("notify之后");
            }
        });
        t2.start();
    }

在这里插入图片描述
  可以看到,这里在notify进行唤醒之后的打印顺序,当遇到wait的时候,进入阻塞,然后直到遇到notify,被唤醒了,才再次执行程序。

  因此,我们可以概括出wait与notify的使用场景。

在这里插入图片描述
  这里还有一个notifyAll,区别如下:
在这里插入图片描述  说了这么多,我们来总结一下wait与notify方法。

  • wait 做的事情:
  1. 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  2. 释放当前的锁
  3. 满足一定条件时被唤醒, 重新尝试获取这个锁.
  4. wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常
  • wait 结束等待的条件:
  1. 其他线程调用该对象的 notify 方法
  2. wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  3. 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常
  • notify 方法是唤醒等待的线程
  1. 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁
  2. 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”).
  3. 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
  • notifyAll()方法
  1. notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程

2. wait 和 sleep 的对比(面试题)

  其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。

  总结如下:

  1. wait 需要搭配 synchronized 使用. sleep 不需要.
  2. wait 是 Object 的方法 sleep 是 Thread 的静态方法.




  最近老是传出裁员的信息,搞到我的定力稍稍受损,但最终还是挺过来,当自己没拥有的时候,说什么都没用,看什么都没用,连被裁,你都得首先有这个实力。
  • 41
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 30
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

十叶知秋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值