线程的状态和安全问题

日升时奋斗,日落时自省 

目录

1、 线程的状态

2、多线程的好处

3、线程安全

4、 线程问题原因

4.1、安全问题原因

4.2、代码结构

4.3、原子性

4.4、内存可见性问题

4.5、指令重排序(编译优化)

5、解决线程问题

5.1、synchronized使用方法

6、加锁(很重要)

6.1、可重入

6.2、java标准库线程安全类

6.3、死锁

 

简单提及一下前面的缺省内容:

线程引用:

public static Thread currentThread();返回当前线程对象的引用

静态方法 直接说是类方法更好

这里用到了static 调用这个方法 不需要实例,直接通过类名来调用

Thread t=new Thread();
Thread.currentThread();
//t.currentThread();

直接用类来调用 返回值正是t这个引用指向的对象

线程休眠:

public static void sleep(long millis)

让线程休眠,这个我们之前都是见过的,就阻塞了,当前线程不参与调度(不在cpu上执行)

操作系统内核:

这个链表里的PCB都是“随叫随到”,就绪状态

1、 线程的状态

状态是针对当前线程调度的情况来描述的

线程是调度的基本单位了,状态更应该是线程的基本属性,(之后提及的状态是线程状态)

针对java 状态细化

(1)NEW 创建Thread 对象,但是还没有调用start(内核里还没有创建一个新的线程)

(2)TERMINATED  表示内核中的线程已经执行完毕了,但是Thread对象还在

(3)RUNNABLE  可运行的  

运行划分为两种情况:

第一种: 正在CPU上执行的   (就你正在用的程序)

第二种 : 在就绪队列里,随时可以去CPU上执行 (打开了,但是当前没有使用,后台放着)

(4)WAITING 

(5)TIMED_WAITING

(6)BLOCKED   

后三个也就是 (4)、(5)、(6)都时不同阻塞情况的状态

下面用一个图像来解释一下6个状态

 那这里的TERMINATED 结束 后,引申问题:为什么Thread对象没有销毁,那不销毁还能在次使用吗?

线程的PCB消亡,代码中t对象也就是没有用了,线程已经结束,自然这个对象也就没有意义了

存在是因为java对象的生命周期,Thread对象没有指向引用了,java会自动回收的,生命周期并不是和系统内核线程一致,内核线程释放,代码中对象不一定立即释放

当前t就是一个“无效”的对象(还可以调用一些属性方法),start也能调用一次,一个线程只能是start一次

为什么start只能使用一次: 避免了t.start使用后,在重启过后是否是有效的无法判断,减少了不必要的麻烦

public class ThreadDemo12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                for (int j = 0; j < 100_0000; j++) {
                    int a = 10;
                    a += 10;
                }
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        //启动之前,获取t的状态 ,就是NEW 状态
        System.out.println("start 之前" + t.getState()); //获取当前状态
        t.start();  //创建线程,并执行run方法
        for (int i = 0; i < 1000; i++) {
            System.out.println("t 执行中的状态" + t.getState());
        }
        t.join();  //等待线程执行结束
        //线程结束完毕之后,就是TERMINTED状态
        System.out.println("t 结束之后" + t.getState());
    }
}

下面图解:解释运行阶段打印一次的状态 ,上面附的是打印多次的代码,可以尝试一下 

解释多次打印后友友可能 想到的问题: 

多次打印后会出现TIMED_WAITING状态的次数比较多 : 

2、多线程的好处

单线程和多线程之间对比,执行速度的差别

程序分开执行

CPU密集,包含了大量的 加减乘除 等算术运算

就拿这个作为实例 解释单线程 和多线程的区别

多线程:在CPU密集型的任务中,有非常大的作用,可以充分利用CPU的多核资源,从而加快程序的运行效率

多线程总结:多个线程开始执行,同时提升效率,执行时间是线程最长时间

注:不是说使用多线程,就能一定提高效率

(1)是否是多核(现在CPU基本都是多核)

(2)当前核心是否有运行(CPU这些核心已经都满载,这个时候启动更多的线程也没有用了)

IO密集型的任务中,也有作用

简单解释:数据文件,就涉及到大量的读硬盘操作,阻塞了界面的响应,多线程也会起到改善作用(例如,游戏在数据缓冲的时候,就会导致你双击后,界面迟迟显示不出来)

3、线程安全

 多线程的抢占式执行,带来的随机性(来自操作系统内核,改不了),就是最大的线程安全问题

如果没有多线程,此时的代码执行顺序就是固定的。单线程按代码顺序执行

如果有了多线程,此时抢占式执行,代码执行顺序,会出现更多的变数,但是又要这每一条执行顺序都是安全的

只要有一种情况下,代码不正确,都被视为有bug,线程不安全(对bug的解释)

还记得刚刚说到多线程好处没有处理的一个问题,为什么每次时间都是不一样的?

这里举出以下这个实例:

class Counter{
    public int count=0;
    public void add(){
        count++;
    }
}
public class ThreadDemo11 {
    public static void main(String[] args) {
        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();
        //等待两个线程 结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //打印最终的count值
        System.out.println("count ="+counter.count);
    }
}

 那调度是随机,如何随机,count++中指令如何进行 图解解释:

 这里简单的举了六个例子,前两个按顺序执行,没有线程安全问题,后面的四个都有线程安全问题

下面图解说明线程问题:

(1)无线程安全问题:(顺序执行)

可以直接有效的进行count++,不会发生count++后只出现一次自增结果(这里执行了两次count++操作,得出结果是2)

 (2)有线程安全问题

count++操作执行两次但是count得出的结果只有1,产生“脏读”问题

 已经执行两个线程的指令,但是count只加了1(出现了“脏读”问题)

此处多线程问题和前面并发事务都是“并发编程”问题,这个问题有一定是基于多线程这样的

一个线程具体执行,就需要先编译CPU指令的任何一个代码都是要编译成很多CPU指令的

当前代码 的指令有哪些

 这里计算出来的count肯定小于10w,那他一定大于5w吗?不一定大于,刚刚给举了一个例子,仅仅限于5w的底线,如果是一下的情况呢

4、 线程问题原因

4.1、安全问题原因

抢占式执行,随机调度,不能改变

4.2、代码结构

多线程同时修改同一个变量(可以调整代码规避问题,特殊情况下可以)

(1)单线程修改变量,不能有问题

(2)多线程修改同一个变量,不能有问题(主要是这个问题大)(String是不可变对象,天然安全保护)

(3)多线程修改不同变量,不能有问题

4.3、原子性

如果修改操作是原子的,还是比较安全的

原子:就是单一的,或者一个整体不能更改,例如:加锁后一个整体就是原子。不可拆分的基本单位,或者是一条单个的指令也是一个“原子”

count++这里已知是三个操作load、add、save(这里随便的一个指令都无法再拆分)

如果count++操作是原子的,线程安全问题就解决了(当前就是“脏读”问题)

这里脏读问题解决就像“并发编程”问题一样,“加锁”变成原子问题迎刃而解

4.4、内存可见性问题

内存可见性问题是什么:在开始我也并不是很理解(这里附一个代码用于下面文字解释)

//volatile 关键词 内存可见性
class MyCounter{
    public int flag=0;
}
//睡眠是不是能和volatile不能在同一个地方使用的,如果一起使用就会导致不生效,玄学问题,主要是我们不能感知到操作系统
public class ThreadDemo16 {
    public static void main(String[] args) {
        MyCounter myCounter=new MyCounter();
        Thread t1=new Thread(()->{
            while(myCounter.flag==0){

            }
            System.out.println("t1 循环结束");
        });
        Thread t2=new Thread(()->{
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数");
            myCounter.flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

以下举一个例子:

代码执行前解释:

 代码执行后图解:

 该情况就是内存可见性问题:是不是还是有点不明白,是的,其实到这里那时的我也是很明白,接下来继续解释:

内存可见性问题总结解释:

(1)一个线程(t1线程)针对一个变量(flag)进行读取,同时另一个线程(t2)针对这个变量(flag)进行修改,此时(t1线程)读到的值,不一定是修改之后的值

(2)这个读线程(t1线程进行读操作)没有感知到变量的变化(编译器/jvm在多线程环境优化时产生了误判)

这里解释一下为什么感知不到

汇编大概需要两步操作(这个循环操作是执行很快的,一秒中执行百万次):
(1)load把内存中flag的值,读取到寄存器里

(2)cmp把寄存器的值,和0进行比较(这里是根据上面的代码进行解释的),根据比较结果,决定下一步往哪个地方执行(条件跳转指令)

循环执行这么多次,在t2真正修改之前,load得到的结果都是一样的

另一方面,load操作和cmp操作相比,速度慢非常多(其实得记一下这里,想理解的话,可以和内存和CPU寄存器进行对比)

注:可以把load和cmp可以类比内存的速度很快,远远快与CPU寄存器

解释:这里就导致JVM出现一个认知错误,load执行速度太慢了(相当于cmp),再加上load的结果都是一样的,JVM的编译优化就做了个非常大胆的决定,没有人改动flag,那就读一次就行了(事实就是如此,友友不信可以试试,我亲测输入很多次,依旧没有用)

这是就需要我们手动让JVM知道一下, 上关键词 : volatile,给变量加上 volatile修饰,意思就是告诉编译器,这个变量是“易变”,每次都记得从新读取。

volatile内存可见性:使用注意:

(1)记得是只能修饰变量 (重点),如果需求变量多了怎么办,挨个加,其实它跟权限很像对一个变量的修饰(我个人见解,不用太当真)

(2)视情况而定,看需求分析,加volatile自然是会降低代码的执行速度

(3)什么时候使用,一个线程读一个线程写,可以考虑使用

代码修改的地方:

 volatile不保证原子性:原子性,是靠synchronized来保证的

synchronized和volatile都能保证线程安全

因为volatile不能保证原子性,所以解决不了线程并发问题 例如:在见面出现的++并发问题

总而言之:内存可见性问题就把 synchronized 和volatile都用上就行了

4.5、指令重排序(编译优化)

这个前面的博客也提起过,编译器很根据你的代码进行优化做已调整(调整代码顺序),保证逻辑不变,加快程序的运行执行效率

5、解决线程问题

原子性来解决问题:“加锁” 成为一个整体,把不是原子的,转换成一个“原子”的

加锁的关键字:synchronized (加锁会单独作为一部分进行详细解释)

我们上面举例的代码加上关键字 

注:java中 会synchronized会进行加锁,也会进行解锁(C++,Python还是需要手动加锁、手动解锁),我感觉java还是方便很多嘞

如果两个线程同学尝试加锁,此时一个能获取锁成功,另一个只能“阻塞等待”(BLOCKED)一直阻塞到刚才那个线程解锁,当前线程才能加锁成功

解决刚才的线程安全问题:

 那给我们代码加上关键字synchronized 看是否解决了 

看似关键字synchronized修饰了 add方法,但是不要这样理解,锁是加给对象,这里是修饰this对象,不是说真的是给方法加了锁,谁调用这个方法谁就会加锁(注:是给对象加锁,给对象加锁,给对象加锁,不要自省脑部给方法加锁)

注:修改的是上面提供的“线程安全”部分附的代码,只改这一处即可(如果有不对,可以留言) 

 运行结果:

我曾经对锁的疑惑:

 友友可以参考:

上面看到加锁后的效果,这个就根据你需要的情况而定了,不是说加锁就一定好,当前了关于“钱”这种涉及的就是越严谨越好,加锁就会能用的上,当需要速度,对准确性需求降低的时候,就可以采取不加锁。

注:思维不要限制 ,锁不是就synchronized这个一个关键词,还有一种锁ReentrantLock这个锁是可以使用这个策略的(获取不到就放弃)

5.1、synchronized使用方法

(1)修饰方法:普通方法,静态方法

普通方法:锁对象是this 

静态方法:和修饰一般方法 大体相同

(2)修饰代码块  : 显示/手动指定锁对象

 构造方法尝试:

 直接给构造方法加修饰是不可以的,可以用静态代码块加锁修饰

是否会产生锁冲突

(1)加锁,是要明确执行那个对象加锁的

(2)如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争/锁冲突),一个线程能够获取到锁(先到先得)另一个线程“阻塞等待”,等待到上一个线程解锁,它才能获取成功

(3)如果两个线程针对不同的对象进行加锁,会产生阻塞等待(不会有锁竞争/锁冲突),这两线程都能获取到各自的锁,不会有阻塞等待了

(4)两个线程,一个线程加锁,另一个线程不加锁,这个时候也没有锁竞争

第(4)图解释(简单写):

总结:锁是同一个对象(产生阻塞),不是同一个对象(不会产生阻塞)

6、加锁(很重要)

锁的来历:monitor lock 监视器锁,synchronized的名字来历,异常中可见

 在加锁的时候也会检查对象,当线程和加锁线程是否是同一个,是同一个那能执行,如果不是就阻塞呗

除了java这里的synchronized之外,其他语言大多都是分开操作锁,加锁(lock),解锁(unlock)分开。

synchronized是一个“原子”指令,不会在过程被调度,那这个指令是如何启动的,CPU提供了加锁指令,操作系统实现锁,操作系统把API提供给JVM,JVM将功能提供给synchronized

6.1、可重入

可重入:一个线程针对同一个对象,连续加锁两次,是否会有问题,如果有问题就说明不可重入,如果没有问题就是可重入

java里这种代码比较常见,所以根据java的设定为了避免不小心引发的死锁,synchronized被设定为可重入,其他语言(C++,Python都是不可重入的)

可重入文字解释不好想出来,代码解释

 可重入其实理解字面意思就行:可以重新进入

实例解释:

这样举一个层叠梦的例子:不知道友友们有没有做过梦中梦

6.2、java标准库线程安全类

在前面String类博客提过一点点线程,String不可改,所以是天然的线程安全

StringBuilder和StringBuffer在叙述时,也提及过,两者具有相同的方法,但是StringBuffer线程安全,因为如果多线程操作同一个集合类,就需要考虑到线程安全的事情

java标准库部分集合类是被直接加锁了的,自带的

Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer
相对来说,内部加锁的更安全,但是慢一点点,强行加锁

 没有加锁的集合类

ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
这些类没有加锁,相对于多线程就有较大的风险了,当然了学了锁不是任何地方都加,是让我们注意多线程带来的问题,不是说是个多线程就有问题,没有线程安全问题,这些集合也可以使用,有线程问题手动加锁解决,更自由,就是使用起来要更谨慎

6.3、死锁

死锁的影响力很大,一旦出现死锁,无法进行后续工作,程序势必互有严重的bug,死锁也很隐蔽,在写代码期间并不容易看出来,不容易测试出来。

在可重入时就提到死锁了

(1)一个线程,一把锁,二次加锁,如果是不可重入锁,就会死锁

前面提到在java中synchronized和ReentrantLock都是可重入锁,其他语言应该能见该现象

(2)两个线程两把锁,但是两个线程都想要两把锁,t1线程拿锁一,t2线程拿锁二,这样就成了死锁了,为什么呢,因为t1线程在等t2线程解锁获取锁二,t2线程在等t1线程解锁获取锁一

举个例子:两个小孩子,吃卷饼,都想加一点沙拉酱和番茄酱,,但是出于小孩子喜欢自己优先,小孩一号拿到了沙拉酱,小孩二号拿到了番茄酱,在交换酱的时候出于好胜心,想先用,一号小孩想先要二号小孩的番茄酱,二号小孩想要一号小孩的沙拉酱,此时出现死锁问题,谁也不愿给谁也拿不到

死锁代码:

public class ThreadDemo14 {
    public static void main(String[] args) {
        Object slj = new Object();    //沙拉酱
        Object fqj = new Object();  //番茄酱
        Thread child_1 = new Thread(() -> {   //一号小孩
            synchronized (slj) {      //加了沙拉酱锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (fqj) {   //加番茄酱锁
                    System.out.println("一号小孩有了沙拉酱和番茄酱");
                }
            }
        });
        Thread child_2 = new Thread(() -> {  //二号小孩
            synchronized (fqj) {//加了番茄酱锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (slj) {//加沙拉酱锁
                    System.out.println("二号小孩有了番茄酱和沙拉酱");
                }
            }
        });
        child_1.start();;
        child_2.start();
    }
}

运行结果:啥也没没有因为两者互不相让

如何来看锁的问题 

 或者直接调试idea 也会弹出一个,但是不是太具体,可能是我不太会用吧

给友友们在这里简单提示一下操作:

idea调试运行之后,然后去点击debug 

 这里sleep是为了确保两个线程先把第一个锁拿到,否则不容易造成当前局面,解释该死锁

死锁问题如何解决,当前先不说,先往下看,自然会有解开之法(不是在这里卖关子,只是现在不好解释)

(3)多个线程,多把锁

操作系统书上的经典案例:哲学家就餐问题

 死锁的四个必要条件:

(1)互斥使用: 线程1 拿到了锁,线程2就得等着 (锁的基本特性)

(2)不可抢占: 线程1 拿到锁之后,必须是线程1 主动释放。不能说是线程2就把锁给强行获取

(3)请求和保持 : 线程1 拿到A锁之后,再尝试获取锁B, A这把锁还保持(不会因为获得的B锁就把A锁给释放了)

(4) 循环等待 :线程 1 尝试获取到锁A和锁B 线程2 尝试获取到锁B和锁A,线程1在获取B的时候等待线程2释放锁B;同时线程2 在获取A的时候等待线程1释放锁A

锁虽然有四个点,但是相对于java语言只有一种是我们可以更改的,(4)我们可以更改的,其他三条都是已经不能改变的,循环等待是这里四个条件里唯一一个核代码相关的,也是我们可以控制的

避免死锁?只要让循环等待中开出一个突破口就解决问题了,哲学家就餐问题中,不难看出,在一个循环等待的过程中,只要有一个循环点结束了当前阻塞,其他点的就迎刃而解了。

办法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,这方法是自己指定的,这里针对刚刚死锁的代码进行更改,自己依照某个顺序可以解决死锁问题即可

解决死锁,最简单的办法;

回到刚刚死锁的代码中(更改):

书上可以还会学到一个解决死锁的办法 叫做银行家算法也能解决,但是实际开发中过于复杂一旦出错,就会引来其他的bug不好更改

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值