【JavaEE初阶】多线程(3)

欢迎关注个人主页:逸狼


创造不易,可以点点赞吗~

如有错误,欢迎指出~



目录

线程状态

线程安全

代码示例 

解释

总结原因

解决方案-->加锁

t1和t2都加锁 且 同一个锁对象

t1和t2中只有一个加锁了

t1和t2都加锁,但锁对象不同

加锁 与线程等待(join) 的区别

使用类对象加锁

加锁场景

死锁

场景1

场景2

​编辑场景3

构成死锁的4个必要条件(缺一不可)

解决方案举例

方案1 避免锁嵌套

方案2 约定加锁顺序

方案3 银行家算法


线程状态

进程状态 分为两种:

  • 就绪:正在cpu上执行,或者随时可以去cpu上执行
  • 阻塞:暂时不能参与cpu执行

Java的线程状态有 6 种

  1. NEW  当前Thread对象虽然有了,但是内核的线程还没有(还没调用start)
  2. TERMINATED 当前Thread对象虽然还在,但是内核线程已经销毁(线程已经结束)
  3. RUNNABLE  就绪状态,正在cpu上运行 +  随时可以去cup上运行
  4. BLOCKED  因为锁竞争引起的阻塞
  5. TIMED_WAITING  有超时的等待 如sleep,或者join带参数版本
  6. WAITING  没有超时 时间的阻塞等待 如 join/wait

学习线程状态主要为了 调试,比如 遇到某个代码功能没有执行,就可以观察对应线程的状态,看是否是因为一些原因阻塞了.

线程安全

多个线程同时执行某个代码时,可能会引起一些奇怪的bug,理解线程安全才能 避免/解决 上述bug

代码示例 

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

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

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

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

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

因为多线程并发执行 引发的bug称为"线程安全问题" 或"线程不安全"

解释

上述代码count++;在cpu视角是3个指令:

  1. load   把内存中的数据读到cpu寄存器里
  2. add    把cpu寄存器里的数据+1
  3. sava  把寄存器的值,写回内存

指令是cpu执行的基本单位,如果要调度,cup至少会把当前指令执行完,

cpu调度执行线程的方式是 抢占式执行,随机调度,

但是由于count++ 是三个指令,可能会出现cpu执行了其中1个指令或2个指令或3个指令就被调度走的情况(都有可能,无法预测), 所以 两个线程同时对count进行++ 就容易出现bug

由于循环5w次过程中不知道有多少次的执行顺序是前两种正确情况,有多少次是其他错误情况,最终的结果就是一个不确定的值,而这个值 一定小于10w

但结果也有可能小于5w

总结原因

  • 线程在操作系统中,随机调度,抢占式执行(根本原因), 此原因无法干预(操作系统内核,作为应用层的程序员无法干预)
  • 多个线程,同时修改一个变量(如果是一个线程修改,就没事)
  • 修改操作,不是"原子"的,(对cpu来说,一条指令才是"原子"的,是不可分割的最小的单位)

解决方案-->加锁

解决线程安全问题,最主要的方法就是 把"非原子" 的修改,变成"原子"(通过加锁,把非原子的修改操作 打包成一个整体,变成原子操作)

t1和t2都加锁 且 同一个锁对象

此处的加锁,没有干预到线程的调度,只是通过加锁,使一个线程在执行count++时,其他线程的count++不能插队进来

Java提供synchronized关键字 来完成加锁操作,synchronizede()的'()'中需要指定一个 "锁对象" (可以指定任何对象)来进行后面的判定

t1和t2都是针对locker对象加锁,t1加锁成功后,继续执行{}里的代码,t2后加锁,发现locker对象已经被别人先锁了,t2只能排队等待(这两者的++ 操作不会并发执行了,本质上是把随机并发的执行过程 强制变成了串行,从而解决了刚才的线程安全问题)

t1和t2中只有一个加锁了

t1和t2都加锁,但锁对象不同

锁对象的作用 就是用来区分 两个线程或多个线程 是否针对"同一个对象"加锁

  • 若是,此时就会出现阻塞(锁竞争/锁冲突)
  • 若不是,此时不会出现"阻塞",两个线程仍然是 随机调度的并发执行.

锁对象,只要是Object(或者其子类)都行,不能是int,double这样的内置类型

加锁 与线程等待(join) 的区别

上述加锁后的代码 本质上要比join的串行执行 的效率还要高

  • 加锁只是把线程中一小部分逻辑 变成了 串行执行,剩下其他部分仍然可以并发执行
  • join是 线程 整体都串行执行

使用类对象加锁

一个Java进程中,一个类的类对象是只有唯一一个的,类对象,也是对象,也可以成为锁对象,写类对象和写其他对象 没有本质区别,换句话说,写成类对象,就是'偷懒'的做法(不想单独创建锁对象了~)

 

加锁场景

是否要加锁,怎么加锁,都是和具体场景直接相关的("无脑加锁"是不推荐的)

锁 ,需要的时候才使用,不需要的时候不要使用,否则会付出代价(性能)

使用锁,就可能会发生阻塞,一旦某个线程阻塞,啥时候恢复阻塞 继续执行是不可预期的~

死锁

场景1

一个线程,针对一把锁,连续加锁两次

Java的synchronized做了特殊处理(引入了特殊机制,"可重入锁"),不会出现 死锁,但同样的代码换成c++/python就会死锁

可重入锁就是在锁中 额外记录一下 当前是哪个线程,对哪个锁加锁了,后续加锁时就会进行判定

还会引入一个引用计数,维护当前已经加锁几次了,并且描述何时真正释放锁

场景2

两个线程,两把锁

  1. 线程1先针对A加锁,线程2针对B加锁
  2. 线程1不释放锁A的情况下,在针对B加锁,同时线程2不释放锁B的情况下对A加锁

这种情况,可重入锁 也无能为力~

package thread;

public class Demo17 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 加锁 locker1 完成");

                // 这里的 sleep 是为了确保, t1 和 t2 都先分别拿到 locker1 和 locker2 然后在分别拿对方的锁.
                // 如果没有 sleep 执行顺序就不可控, 可能出现某个线程一口气拿到两把锁, 另一个线程还没执行呢, 无法构造出死锁.
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                System.out.println("t2 加锁 locker2 完成");

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker1) {
                    System.out.println("t2 加锁 locker1 完成");
                }
            }
        });

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

场景3

N个线程,M个锁 

经典问题:哲学家就餐问题

当每根筷子都被哲学家左手拿起来了,他们右手就没有筷子可以拿了,当哲学家吃不到面条时,也就不会放下左手的筷子, 此时就产生了 死锁.

构成死锁的4个必要条件(缺一不可)

锁的基本特性:

  • 1.锁是互斥的 . 如 一个线程拿到锁,另一个线程就拿不到这个锁
  • 2.锁是不可被抢占的.  如 线程1拿到了锁A,若线程1不主动释放A,线程2不能把锁A抢过来

对于synchronized 这样的锁,互斥和不可抢占都是基本特性, 无法干预

代码结构上:

  • 3.请求 和 保持. 如 线程1拿到锁A之后,不释放A的前提下,去拿锁(解决方法: 如果是 先释放A,再拿B,不会有问题)
  • 4.循环等待 / 环路等待 / 循环依赖. 如 多个线程获取锁时,存在 循环等待( 解决方法:如果在获取多把锁的时候,不要构成循环等待就行了)

解决方案举例

针对场景2,通过改变代码结构 解决死锁问题(对症下药~)

方案1 避免锁嵌套

针对 死锁构成条件3 

方案2 约定加锁顺序

针对 死锁构成条件4 

给锁进行编号1,2,3,4...N,约定所有的线程在加锁时都必须按照一定的顺序加锁(比如,必须先针对编号小的锁,加锁,后对大的加锁)

方案3 银行家算法

但是银行家算法太复杂了,如果在日常开放中,实现一套银行家算法解决死锁,先不说死锁的问题是否存在,你实现的银行家算法本身可能存在bug~

评论 40
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值