【JavaEE】线程安全与线程不安全问题手术刀剖析

文章目录

  • 一、线程的状态
    • 1. 线程状态和状态转移的意义图
  • 二、线程安全
    • 1.概念
    • 2.如何加锁?
  • 三、线程不安全的原因及解决方式
  • 最后

一、线程的状态

  之前的文章有提过,我们线程的状态有就绪状态和阻塞状态。这里的状态就决定了系统按照怎样的方式来调度这个进程。 但是这样的说法其实是不严谨的,因为这是针对一个进程只有一个线程的情况。但在实际情况下,更多是一个进程包含多个线程。我们所谓的状态,其实是绑定在线程上而言的。

  在Linux中,PCB(process control blocked)(进程控制块)其实是和线程对应的,一个进程对应着一组PCB,因此我们每个线程都是独立的状态。那么我们系统在调用线程的时候,就可以根据每个线程不同的状态,来决定是调度它还是暂缓调度。通过这个不同的状态,就可以更好的进行区分了。

  以上内容是针对系统层面上线程的状态而言的。

  实际上,在Java的Thread类中,对于线程的状态,又进一步的细化了。那么,它是如何细化的呢?它分为以下几种:

  1. NEW: 安排了工作, 还未开始行动
  2. RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.
  3. BLOCKED: 这几个都表示排队等着其他事情
  4. WAITING: 这几个都表示排队等着其他事情
  5. TIMED_WAITING: 这几个都表示排队等着其他事情
  6. TERMINATED: 工作完成了

  我们接下来逐个解释一下。

  1.NEW:安排了工作,还未开始行动

public class Demo14 {
    public static void main(String[] args) {
        Thread t = new Thread(()->{

        });
        System.out.println(t.getState());
        t.start();
    }
}

【JavaEE】线程安全与线程不安全问题手术刀剖析_第1张图片  也就是说,我们把Thread对象创建好了,但是还没有调用start。

  2.TERMINATED: 工作完成了
【JavaEE】线程安全与线程不安全问题手术刀剖析_第2张图片  这里的1和2状态时Java内部搞出来的状态,与操作系统中的PCB里状态没多大关系。

  3.RUNNABLE:可工作的,又可以分成正在工作中和即将开始工作。

    这个状态也是我们常说的就绪状态,处在这个状态的线程,就是在就绪状态中,它随时可以被调度到CPU上。一般来说,大部分代码都处在就绪状态中。如果代码没有进行sleep,也没有进行其他的可能导致数组阻塞的操作,我们的代码很可能就处在Runnable状态中。

【JavaEE】线程安全与线程不安全问题手术刀剖析_第3张图片

  

  4.TIMED_WAITING: 这几个都表示排队等着其他事情
    在代码调用了sleep,或者说join(超时时间)这两个方法就会进入到TIMED_WAITING状态中,表示当前的线程在一定的时间内,处在阻塞状态。当时间到了之后,阻塞状态解除。这也属于阻塞的状态之一。

【JavaEE】线程安全与线程不安全问题手术刀剖析_第4张图片

  5.WAITING: 这几个都表示排队等着其他事情
    这个也是阻塞的状态之一,表示当前线程在等待唤醒,从而导致了阻塞。这个一般在用wait方法等待唤醒的时候会触发这种状态。

  6.BLOCKED: 这几个都表示排队等着其他事情

    这个也是阻塞的状态之一,表示当前线程在等待唤醒,从而导致了阻塞。这个一般在用synchronized方法等待唤醒的时候会触发这种状态。

  最后两个在下面介绍wait和synchronized的时候在详细介绍。

    
  
  细分的好处:我们在开发过程中,经常会遇到程序卡死的状况,这种情况下实际上就是关键的线程发生了阻塞。那么我们第一步就可以通过分析当前程序里的各种关键线程所处的状态,来进行判定其原因。

1. 线程状态和状态转移的意义图

【下图来自网络】

  这里也找了一个网上的例子方便理解:
【JavaEE】线程安全与线程不安全问题手术刀剖析_第5张图片
  刚把李四、王五找来,还是给他们在安排任务,没让他们行动起来,就是 NEW 状态;

  当李四、王五开始去窗口排队,等待服务,就进入到 RUNNABLE 状态。该状态并不表示已经被银行工作人员开始接待,排在队伍中也是属于该状态,即可被服务的状态,是否开始服务,则看调度器的调度;

  当李四、王五因为一些事情需要去忙,例如需要填写信息、回家取证件、发呆一会等等时,进入BLOCKED 、 WATING 、 TIMED_WAITING 状态,至于这些状态的细分,我们以后再详解;

  如果李四、王五已经忙完,为 TERMINATED 状态。

二、线程安全

1.概念

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

  线程安全的问题是整个多线程中最重要,也是最复杂的问题。而正是由于这样,很多程序员高手才会努力去整出更多的编程模型来处理并发编程的问题,例如说,我们的多进程,多线程,这两个是最基本的。除此之外,还有actor模型,csp模型,js中的定时器+回调之类的模型。

  这里的线程安全,更通俗的讲是我们代码里面有没bug,如果有,则认为代码是线程不安全的;如果没有,则认为是安全的。

  产生的原因是操作系统在调度线程的时候,是随机的,也就是会抢占式执行。正是由于这样的随机性,就可能导致我们的程序出现一些bug,导致线程不安全。

  这里有一个线程不安全的典型案例:使用两个线程,对同一个整型变量进行自增操作,每个线程自增五万次,看最终结果。


class Counter{
    //这个变量就是两个线程要去自增的变量
    volatile public int count;

    public void increase(){
        count++;
    }
    public static void func(){

    }
}

public class Demo15 {

    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();

        //必须在t1和t2都执行完了之后,再打印count的结果
        //否则,main和t1、t2之间都是并发的关系,这会导致t1、t2还没执行完就先执行下面的打印操作
        t1.join();
        t2.join();

        //在main中打印一下两个线程之后,得到count结果
        System.out.println(counter.count);
    }
}

【JavaEE】线程安全与线程不安全问题手术刀剖析_第6张图片
【JavaEE】线程安全与线程不安全问题手术刀剖析_第7张图片
  为什么会出现这种情况呢?我们下面来详细分析一下。count++它是如何++的。

  这里我们需要站在CPU的角度上来看待。这里的count++,实际上是有三个CPU指令,分别是load,add,save。为了更好的阅读,我们做一个表格:

指令 功能
load 把内存中的count的值加载到CPU寄存器中
add 把寄存器中的值+1
save 把寄存器中的值写回到内存的count中

  打个不怎么恰当的比喻,如何把大象放到冰箱里面?

  1. 打开冰箱
  2. 把大象放进冰箱
  3. 关闭冰箱

  大概就是这么一个过程。

  那么这里正是由于前面说得操作系统在调度线程的时候,是随机的,是抢占式执行的,这就导致了两个线程同时执行这三个指令的时候,顺序上充满了随机性。


  我们下面通过图来理解:

【JavaEE】线程安全与线程不安全问题手术刀剖析_第8张图片  我们可以看到,在“抢占式执行”情况下,t1和t2的这三个指令之间的顺序是充满随机性的,在上述例子的5w对并发相加的情况中,有时候可能是串行(+2),而有的时候却是交错的(+1),具体串行多少次,交错多少次,都是随机的,都不知道。这些都是无法预测的。

  那么,该如何避免或者处理上面问题呢?

  答案是加锁!我们通过下图了解一下:
【JavaEE】线程安全与线程不安全问题手术刀剖析_第9张图片

2.如何加锁?

  Java中的加锁方式有很多种,最常使用的就是synchronized这个关键字。

【JavaEE】线程安全与线程不安全问题手术刀剖析_第10张图片
  讲了这么多,那么怎么样的代码会产生线程不安全的问题呢?

三、线程不安全的原因及解决方式

(1)线程是抢占式执行的,所以线程间的调度充满了随机性,这个是线程不安全的万恶之源。面对这种原因,我们是没有办法去解决的。

(2)多个线程对同一个变量进行修改操作。那么这里可以通过调整代码结构使不同的线程操作不同的变量即可。

(3)针对变量的操作不是原子性

  这里需要解释一下什么是原子性:

原子性:是指一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证的话,那么在A进入房间之后,它还没有出来,B是不是也可以进入房间,打断 A 在房间里做的的隐私的事情?这种情况就说明不具备原子性。

那么只要给房间加一把锁,A 进去就把门锁上,其他人就进
不来了。这样就保证了这段代码的原子性了

  有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
  一条 java 语句不一定是原子的,也不一定只是一条指令.

  比如刚才我们看到的 count++,其实是由三步操作组成的

  1. 从内存把数据读到 CPU
  2. 进行数据更新
  3. 把数据写回到 CPU

  不保证原子性会给多线程带来什么问题?
  如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

  这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大。

  

(4)内存可见性也会影响到线程安全

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

  那么它是如何影响的呢?

  回到我们开始那个例子:

【JavaEE】线程安全与线程不安全问题手术刀剖析_第11张图片
  上述会产生这样的结果,是因为我们的Java编译器会对代码进行优化,编译器对程序猿写出的一些不好的代码,在原逻辑不变的前提下做出一些调整,使得程序的执行效率大大提高。但在此处却产生了反面效果。

  一个实例:

【JavaEE】线程安全与线程不安全问题手术刀剖析_第12张图片
  那么这种情况,我们该如何去解决呢?

  1. 使用synchronized关键字,synchronized不光能保证指令的原子性,同时也能保证内存可见性,被synchronized包裹起来的代码,编译器就不敢轻易得做出上述的行为了,这相当于手动禁用了编译器的优化。

  2. 使用volatile关键字,这个volatile关键字和原子性无关,但是它能够保证内存可见性,换句话说,就是禁止编译器做出上述优化,也就是编译器每次执行,都会进行判断相等,都会重新从内存读取isQuit的值.

【JavaEE】线程安全与线程不安全问题手术刀剖析_第13张图片

  内存可见性是属于编译器优化的一个典型案例,而编译器的优化恰恰是一个很玄学的问题,什么时候优化,什么时候不优化,很难说,比如说刚刚上面的问题,我们不用关键字也行,如下图:

【JavaEE】线程安全与线程不安全问题手术刀剖析_第14张图片

(5)指令重排序也会影响到线程安全的问题。
  重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。

  这个情况使用synchronized也是可以解决的,它不仅可以保证原子性,同时还能保证内存可见性,同时还能禁止指令重排序。

  那么synchronized这个关键字,我们下一篇博客再介绍。

最后

  来来回回,写了挺多东西的,渐渐明白,这些东西是为自己服务的,所以现在对待的心态也就不一样了,自己明白,现在是在构建大厦的水泥阶段,如果连水泥、钢筋都没有的话,还构建啥?

你可能感兴趣的:(JavaEE,多线程,java,java-ee,后端)