JavaEE 初阶(6)——多线程4之线程状态+线程安全上

目录

一.线程状态

二.线程安全

2.1 观察线程不安全

2.2 线程安全的概念

2.3 线程不安全的原因

a.线程调度是“随机”的

b.多个线程修改共享数据

c.操作不是 “原子” 的

d.可见性

e.Java内存模型(JMM)

1)为啥要整这么多“内存”?

2)为啥要这么麻烦的拷来拷去?

f.指令重排序

2.4 解决线程不安全问题1 -- synchronized关键字

a. synchronized的介绍

 b. synchronized的使用


一.线程状态

  • NEW: 当前Thread对象虽然有了,但是内核的线程还没有(还没调用start)。即安排工作了,还未开始行动
  • RUNNABLE:就绪状态,正在cpu上运行 / 随时可以去cpu上运行。即可工作的,又可以分为正在工作和即将开始工作
  • BLOCKED:因为 锁竞争,引起的阻塞,暂时不能参与cpu执行。几个都表示排队等着其他事情
  • WAITING:没有超时间的阻塞等待 join的无参版本 / wait。几个都表示排队等着其他事情
  • TIMED_WAITING:有超时间的阻塞等待 比如sleep / join的带参数版本。几个都表示排队等着其他事情
  • TERMINATED:线程结束终止。即工作完成

上述线程状态,也可以通过jconsole来观察

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

二.线程安全

2.1 观察线程不安全
public class Conflict {
    private static int count = 0;

    public static void main(String[] args) {
        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();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

运行结果:

预期结果:100000

很明显,这段代码出现了bug !!! 因为多个线程并发执行,引起的bug,称为“线程安全问题”或“线程不安全”


  上面的 count++ 操作,其实在 cpu 视角看来,是3个指令:

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

(这里的load add save指令 只是通俗的表达方式,由于不同架构的cpu有不同的指令集,不同的指令集里都有不同的指令,针对这三个操作,不同 cpu 里对应的指令名称肯定是不同的。 x86 的 cpu 和 arm 的 cpu 和 mips 的 cpu 和risc-V 的 cpu 都会有对应的操作,但是具体指令的名字会有差别.....)

  cpu 调度执行线程的时候,是“随机调度,抢占式执行”的,说不上啥时候就会把线程给切换走。

  指令 是 cpu 执行的 最基本单位,要调度执行,至少把当前指令执行完,不会执行一半就调度走。

  但是由于count++是三个指令,可能会出现 cpu 执行了其中1个或2个或3个指令....调度走的情况(无法预测)

  基于上面情况,两个线程同时对 count进行++,就容易出现bug

 这个画法的含义,就是 t1 先执行load,add,save,然后 t2 执行load,add,save。按照这个执行顺序,输出的结果与串行执行没有区别,不会有bug。

但是,上述执行顺序,只是一种可能的调度顺序。由于调度过程是“随机”的,因此就会产生很多其他执行顺序。

上述过程中,明明是++了两次,但是最终结果还是1。这两次加的过程中,结果出现了“覆盖”。

由于循环5w 次的过程中,不知道有多少次执行的顺序是按照完整的 3 个++操作指令执行的,因此最终的结果 是不确定的值,而且这个值一定小于 10w。

但这个值是否一定大于5w? 答:不一定  

 如果是下面这种调度情况,完全可能出现 t1 ++ 一次的过程中 t2 ++ 两次。应该最终得到 3,实际上只得到 1。(这种情况比较少)

2.2 线程安全的概念
如果多线程环境下,代码运行的结果是符合我们预期的,即在单线程环境下运行的结果,则说这个程序是线程安全的。
2.3 线程不安全的原因
a.线程调度是“随机”的

对于多线程代码来说,最大的困难,就在于“随机调度,抢占式执行”(多线程编码的“罪魁祸首,万恶之源”) 程序员必须保证 在任意执行顺序下,代码都能正常工作。

b.多个线程修改共享数据

如果只有一个线程,是没有问题的;如果多个线程 同时 修改同一个变量,此时就会涉及到线程安全问题。

上面线程不安全问题中,涉及到多个线程对 count 变量进行修改。此时,这个 count 是一个多个线程都能访问到的“共享数据”。

c.操作不是 “原子” 的

原子性:在计算机中,原子性指一个操作在执行过程中 不会被中断 的特性。即这个操作在执行完毕之前,不会被任何其他操作打断,其执行过程是连续不可分割的。在多线程环境中,原子性是保证数据一致性和避免竞态条件的重要概念。

 以下是关于Java中原子性的几个要点:

1.基本类型变量的读取和写入

  • 在Java中,对基本数据类型(如int,float,double等)的读取和写入操作是原子的,这意味着对于这些类型的单个变量,操作将不会被线程调度机制所中断。

2.复合操作的非原子性

  • 尽管基本类型的操作是原子的,但对于复合操作(如自增 i++,自减 i--,if,while语句....)都不是原子的。

在 cpu 的视角,一条指令 就是 cpu 上的不可分割的最小单元。cpu 在进行调度切换的时候,势必会确保执行完一条完整的指令(取指令,解析指令,执行指令)

d.可见性

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

e.Java内存模型(JMM)

Java虚拟机规范中定义了Java内存模型。目的是 屏蔽掉各种 硬件 和 操作系统 的 内存访问差异以实现让Java程序在各种平台下都能达到一致的并发效果。 

  • 线程之间的共享变量存在“主内存”(Main Memory)
  • 每一个线程都有自己的“工作内存”(Working Memory)
  • 当线程要读取一个共享变量的时候,会先把变量从“主内存”拷贝到“工作内存”再从工作内存读取数据。
  • 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存

由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的“副本"。此时修改 线程1 的工作内存中的值,线程2 的工作内存不一定会及时变化。

此时引入了两个问题:

  •  为啥要整这么多内存?
  • 为啥要这么麻烦的拷来拷去?
1)为啥要整这么多“内存”?
  •     实际上,并没有这么多“内存”,这只是 Java 规范中的一个术语,属于“抽象”的说法。所谓的“主内存”才是真正硬件角度的“内存”;而所谓的“工作内存”,则是指 cpu 的 寄存器高速缓存
2)为啥要这么麻烦的拷来拷去?
  •     因为 cpu 访问自身寄存器 的速度 以及 高速缓存 的速度,远远超过 访问内存 的速度(快了 3-4 个数量级,也就是几千倍,上万倍)

比如某个代码中要连续10次读取某个变量的值,如果 10 次都从内存中读,速度是很慢的。但是,如果只是第一次从内存中读,读到的结果缓存到 cpu 的某个寄存器中,那么后9次就不必直接访问内存了。

f.指令重排序

 什么是代码重排序?

一段代码是这样的:

  1. 去前台取下U盘
  2. 去教室写10min作业
  3. 去前台取下快递

如果是在单线程的情况下,JVM、CPU指令集会对其进行优化,比如,按1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序。

编译器对于 指令重排序 的前提是“保持逻辑不发生变化"。这一点在单线程环境下比较容易判断,但是在多线程环境下就没那么容易了。多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价。

2.4 解决线程不安全问题1 -- synchronized关键字
a. synchronized的介绍

解决线程安全问题,最主要的办法就是把“非原子”代码变成“原子”代码 -->“加锁

此处的加锁,并不是真的让 count++ 变为原子的,也没有干预到线程的调度,只不过是通过加锁的这种方式,使一个线程在执行 count++ 的时候,其他线程的 count++ 不能插队进来。

类似于上厕所锁门一样:

public class Lock1 {
    private static int count = 0;

    private static Object locker = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //对count++操作加锁
                synchronized(locker){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count="+count);
    }

}

运行结果: 

t1 和 t2 都对 locker对象 进行加锁。t1 先加锁,代码块中的内容处于 lock 状态,当 t2 想要执行加锁的代码块时,t1的 lock 产生阻塞,直到 t1 出代码块,状态变为 unlock,t2 才能 lock,并执行代码块。

synchronized是关键字,不是函数:
( ) 并非是参数,而是需要指定一个“锁对象”,通过“锁对象”来进行后续的判定(这里的“锁对象”可以指定任何对象)
{ } 里面的代码,就是要打包到一起的代码,可以放任何代码,包括调用别的方法啥的,只要使合法的Java代码都可以。进入代码块,就会进行加锁;出了代码块,就会进行解锁。


此处的 synchronized 是 JVM 提供的功能,synchronized 底层实现就是 JVM 中,通过C++来实现,即依靠 “操作系统” 提供的 API 实现加锁,操作系统 的 API 则是来自于 cpu 上支持的特殊指令来实现的。

系统原生加锁API实际上是两个函数:加锁是一个函数,解锁是一个函数这种做法最大的问题,在于 unlock 可能执行不到。不仅仅原生API 是这样的,很多编程语言也是类似的封装模式(C++/Python)。而Java中的synchronized是进入代码块就加锁,出了代码块就解锁。因此,在加锁的代码块中无论 执行return 还是 抛出异常,出了代码块都会自动解锁——有效避免没有执行解锁的情况。像 Java 这种通过synchronized关键字来 同时完成 加锁 和 解锁,是比较少见的。

  •  锁对象,是用来区分多个线程之间是否是针对 “同一个对象” 加锁。如果针对同一个对象加锁,此时就会出现 阻塞”(锁竞争/锁冲突;如果不是针对“同一个对象”加锁,此时不会出现“阻塞”,两个线程仍然是随机调度的并发执行。 锁对象 填哪个对象不重要;重要的是,多个线程是否是 同一个 锁对象!!

本质上是把随机的并发执行过程,强制变成了串行,从而解决了刚才的线程安全问题。

注: 此处加锁后的代码,本质上比 join() 的 “串行执行” 效率高。join() 是“整体串行”,而加锁是代码“部分串行”。

加锁,就是变成 “串行执行” ,那是否就没必要使用多线程了?当然不是的!加锁,只是把线程中的一小部分逻辑,变成“串行执行”,剩下的其他部分,仍然是可以并发执行的!!比如,这里的 for循环 比较,i++操作都是可以并发执行的。只有锁里面的 count++ 是串行,外面的仍然并发执行。


刚才看到的情况,是两个线程针对一个锁加锁。如果是更多线程呢?

如果是 3 个线程对同一个对象加锁,其中某个线程先加上锁,另外两个线程 阻塞等待(但是哪个线程会先拿到锁,是不可预期的...

 b. synchronized的使用

1)修饰代码块:明确指定锁哪个对象

锁任意对象:

public class Lock2 {
    private Object locker = new Object();
    private void method(){
        synchronized (locker){
           
        }
    }
}

锁当前对象:

public class Lock3 {
    public void method(){
        synchronized (this){

        }
    }
}

2)直接修饰普通方法:锁的 当前类 this对象

public class Lock4 {
    public synchronized void method(){
        
    }
}

        

3)修饰静态方法:锁的 当前类对象,与this无关

public class Lock5 {
    public synchronized static void method(){
        
    }
}

           


总结: 

  1. 理解锁对象的作用:可以把任意的Object/Object子类的对象 作为锁对象,但对象是啥不重要,重要的是,两个线程的锁对象 是否是同一个 —— 是同一个,才会出现 阻塞/锁竞争;不是同一个,不会出现 阻塞/锁竞争 的。
  2. synchronized几种使用方式:1)synchronized 修饰代码块,圆括号指定锁对象    2)synchronized 修饰一个普通方法,相当于针对 this 加锁    3)synchronized 修饰一个静态的方法,相当于对 对应的类对象 加锁
  • 10
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值