JavaEE--多线程(续)安全问题

多线程续

  • 一、Thread常用属性
  • 二、中断一个线程
    • 创建变量法
    • 内置标志位法
  • 三、等待一个线程
  • 四、线程的状态
  • 五、线程安全
    • 1、抢占式执行
    • 2、多个线程修改同一个变量
    • 3、修改操作不是原子的
    • 4、内存可见性
    • 5、指令重排序
  • 六、解决线程安全问题
    • 1、加锁
    • 2、和join操作区别
    • 3、总结

上篇博客中写道计算机是如何工作的以及多进程和多线程的区别。最后用Thread多种方法创建了线程,其中的lambada表达式是最重要的
上篇博客链接:链接: link

一、Thread常用属性

start方法:真正从系统这里创建一个线程,新的线程将会执行run方法。
run方法:表示了线程的入口方法是啥。线程启动起来,要执行哪些逻辑;(不是让程序员调用的,要交给系统去自动调用)。
这两个方法也是经典的面试题。

二、中断一个线程

中断线程,就是让一个线程停下来。本质上,让一个线程终止,方法就一个,让线程的入口方法(run方法)执行完毕。

创建变量法

JavaEE--多线程(续)安全问题_第1张图片

上述代码中while(true)造就了是一个死循环,导致了入口方法无法执行完毕,自然就不能结束线程。所以我们把循环条件用一个变量来控制。代码如下:

public class ThreadDemo9 {
    public static boolean isQuit = false;

    public static void main(String[] args) {
        Thread t = new Thread( () -> {
            while (!isQuit) { // while(true)是死循环,导致入口方法无法执行完毕,自然不能结束线程
                System.out.println("hello t");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t 线程终止");
        });

        t.start();

        // 在主线程中,修改isQuit
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isQuit = true;
    }
}

运行结果如下:
JavaEE--多线程(续)安全问题_第2张图片
三秒后isQuit变为true,线程结束。
提问,如果将isQuit从成员变量改成局部变量main,改代码能否正常工作?
答案是不行。首先lambada表达式是可以访问外面的局部变量的。但是必须遵守变量捕获的语法规则。Java要求变量捕获,捕获的变量必须是final或者“实际final”(变量中没有用final修饰,但是代码中并没有做出修改)。而这里将boolean写进mian方法中,下面我们会把siQuit修改为true,因为不符合变量捕获的原则,所以不行。

内置标志位法

事实上,Thread类内置了一个标志位,让我们更方便的实现上述效果,代码如下:

public class ThreadDemo10 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            // currentThread是获取当前线程实例
            // 此处currentThread得到的对象就是t,也可以将t直接替换
            // isInterrupted就是t对象里自带的一个标志位
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("hello t");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t.start();

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace(); //打印出当前异常位置的调用栈
        }

        // 把t内部的标志位设置成true,默认是isInterrupt是false状态
        t.interrupt();
    }
}

运行结果如下:
JavaEE--多线程(续)安全问题_第3张图片
此时我们发现3s时间到,调用t.interrupt方法的时候,线程并没有真的结束,而是打印了个异常信息,又继续执行了。这个异常就是代码中catch里面e.printStackTrace调用的。
JavaEE--多线程(续)安全问题_第4张图片
(一)interrupt方法的作用:
1、设置标志位为true。
2、如果该线程正在阻塞中(比如在执行sleep),此时就会把阻塞状态唤醒,通过抛异常的方式让sleep结束。
注意!
当sleep被唤醒的时候,sleep会自动的把isInterrupted标志位清空。(true->false)。
(本来isInterrupted是未中断false,调用interrupted变成已中断true,接下来因为已中断导致sleep被提前唤醒,一旦sleep被提前唤醒,sleep就会把isInterrupted标志位由true再置回为false。)这就导致下次循环,循环仍可以继续执行了。
(二)下次循环为什么不会抛异常?
sleep第一次执行,清空了标志位,并抛出了异常,sleep第二次执行,没有中断的标志位了(主线程并非是循环反复设置的,而是只实行一次)。
JavaEE--多线程(续)安全问题_第5张图片
如图:isInterrupted标志位开始是false
JavaEE--多线程(续)安全问题_第6张图片
如果sleep执行的时候看到这个标志位是false,sleep正常进行休眠工作,如果当前位置是true,sleep无论是刚刚执行还是已经执行了一半,都会触发两件事。1、立即抛出异常。2、清空标志位为false。再下次循环,到sleep由于当前标志位本身就是false,就不会执行任何操作。
如果设置interrupt的时候,恰好sleep刚醒,这个时候巧了,执行到下一轮循环的条件就直接结束了(但是这种概率非常低,毕竟sleep的时间占据了整个循环体的99.9999……%的时间了)。
(三)主线程只调用一次
这是一个多线程代码,多线程代码执行顺序不是从上到下的,而是每个线程独立执行的。执行到start方法,代码兵分两路,主线程继续往下执行,新线程进入到run方法执行,这两者为并发(并发+并行)执行。主线程执行完interrupt就继续往后走了,不会再执行interrupt了。
JavaEE--多线程(续)安全问题_第7张图片
interrupt执行完主线程就完了,main方法后面也没别的了,但是t要不要结束还得看t里面的内容。
(四)为什么sleep要清空标志
目的是为了让线程自身能够对于现车给何时结束有一个明确的控制,当前interrupt方法没效果不是让线程立即结束,二十告诉它,该结束了,至于是否真的要结束立即结束还是等会结束,都是代码灵活控制的,interrupt只是通知,而不是命令。线程何时结束,交给t自身来决定。
(五)修改让循环结束
如果需要循环结束,就得在catch中搞一个break。如图:
JavaEE--多线程(续)安全问题_第8张图片

三、等待一个线程

线程之间是并发执行的,操作系统对于线程的调度是无序的,无法判定两个线程谁先执行结束,谁后执行结束。如下图所示:
JavaEE--多线程(续)安全问题_第9张图片
先输出hello main还是hello t无法确定的,这个代码执行的时候大部分情况下都是先出hello main(因为线程创建也有开销)但是不排除特定情况系啊,主线程hello main没有立即执行到。这个时候,就可以使用线程等待来实现。(join方法):

public class ThreadDemo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println("hello t");
        });

        t.start();

        Thread.sleep(1000);

        t.join();// main线程中调用t.join意思是让main线程等待t先结束,再往下执行

        System.out.println("hello main");
    }
}

(1)在t.join执行的时候,如果t线程还没结束,mian线程就会阻塞等待。代码走到这一行就停下来,当前这个线程暂时不参与cpu的调度执行了。
(2)t.join是在main线程中,调用t.join,意思就是让main线程等待t先结束,再往下执行,别的线程不受影响(如果是t1线程中,调用t2.join意思就是让t1线程等待t2线程结束,t1进入阻塞,其他线程正常调度。
(3)总结就两点:1、main线程调用t.join的时候,如果t还在银杏,此时main线程阻塞,直到t执行完毕(t的run执行完了),main才从阻塞中解除,才继续执行。2、main线程调用t.join的时候,如果t已经结束了,此时join不会阻塞,就会立即往下执行。

四、线程的状态

操作系统里的线程,自身是有一个状态的。但是Java Thread是对系统线程的封装,把这里的状态又进一步的精细化了。

NEW:系统中的线程还没创建出来,只是有个Thread对象。
TERMINATED:系统中的线程执行完了,Thread对象还在,工作完成了。
RUNNABLE:就绪状态:1、正在cpu上运行。2、准备好随时可以去cpu上运行。
TIME_WAITING:指定时间等待.sleep方法。
BLOCKED:表示等待锁出现的状态。
WAITING:使用wait方法出现的状态。

JavaEE--多线程(续)安全问题_第10张图片
代码举个例子:

public class ThreadDemo12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                // 防止hello把线程冲掉看到不到状态,先注释掉
                // System.out.println("hello");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 在启动之前,获取线程状态,NEW
        System.out.println(t.getState());

        t.start();

        Thread.sleep(2000);
        System.out.println(t.getState());
    }
}

五、线程安全

1、抢占式执行

某个代码,在多线程环境下执行,会出bug,也就是线程不安全本质上是因为线程之间的调度顺顺序是不确定的。我们先举个例子,看如下代码:

class Counter {
    private int count;

    public void add() {
        count++;
    }

    public int get() {
        return count;
    }
}

// 不安全的线程
public class ThreadDemo13 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        // 创建两个线程,两个线程分别对这个counter自增5w次
        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();

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

        System.out.println(counter.get());
    }
}

这个代码,是两个线程针对同一个变量,各自自增5w次。预期结果应该是10w次,实际结果确实一个随机值,每次结果都不一样。(实际结果和预期结果不相符,就是bug,由多线程引起的bug->线程不安全。)运行结果如下:
JavaEE--多线程(续)安全问题_第11张图片
JavaEE--多线程(续)安全问题_第12张图片
JavaEE--多线程(续)安全问题_第13张图片
三次运行结果都不同。为什么会出现这种原因?这就和线程的调度随机性密切相关。

count++操作,本质上是三个cpu指令构成:
1、load:把内存中的数据读取到cpu寄存器中。
2、add:就是把寄存器中的值,进行+1操作。
3、save,把寄存器中的值写回到内存中。

由于多先线程调度舒徐是不确定的,实际执行过程中,这俩现车给的++操作实际的指令排列顺序就有很多可能!
JavaEE--多线程(续)安全问题_第14张图片
这一种t1和t2的load,然后add,最后save同时运行。
JavaEE--多线程(续)安全问题_第15张图片

……有很多种排列组合方式
JavaEE--多线程(续)安全问题_第16张图片
JavaEE--多线程(续)安全问题_第17张图片
JavaEE--多线程(续)安全问题_第18张图片
由这几种我们可以知道这俩线程调度顺序是无序的,运行过程中不知道这俩线程自增过程中,到底经历了什么,有多少次是“顺序执行”,有多少次是“交错执行”不知道,得到的结果也是变化的。
这个代码出现的结果一定是<=10w。

总结:归根结底,先线程安全问题,全是因为线程的无序调度,导致了执行顺序不确定,结果就变化了。即线程在系统中的调度是无序的/随机的(抢占式执行)

2、多个线程修改同一个变量

上期博客回顾:
一个线程修改同一个变量->安全
多个线程读取同一个变量->安全
多个线程修改不同的变量->安全

3、修改操作不是原子的

原子是指不可分割的最小单位。上述+操作,不是原子的,里面可以拆分成三个操作=>load,add,save。某个操作,对应单个cpu指令,就是原子的。如果这个操作对应多个cpu指令,大概率就不是原子的。
(正是因为不是原子的,导致两个线程的指令排列存在更多的变数了)。
如果是直接使用 = 赋值,就是一个原子的操作。

4、内存可见性

我们先写一个bug(实际效果!=预期效果):

   public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
           while (flag == 0) {
               // 空着
           }
            System.out.println("循环结束!t1结束!");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            flag = scanner.nextInt();
        });

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

这个代码预期效果:
t1通过flag == 0作为条件进行循环,初始情况,将进入循环。t2通过控制台输入一个整数,一旦用户输入了非0的值,此时t1的循环就会立即结束,从而t1线程退出!
但是实际效果是:
输入非0的值之后,t1线程并没有退出,循环没有结束,通过jconsole可以看到t1线程仍然在执行,仍在RUNNABLE状态。
这就是内存可见性导致的不安全问题。分析如下:
我们看这一段代码:
在这里插入图片描述
会进行以下两个操作:
1、load:从内存读取到cpu寄存器
2、cmp:比较寄存器里的值是否是0。
注意!这俩操作,load的时间开销远远高于cmp。读内存虽然比读硬盘快(几千倍)但是读寄存器,又比读内存要快(几千倍)。
编译器发现:load开销很大,每次load的结果都一样(这个代码里面对flag没有修改)此时编译器就做了一个大胆操作!=>把load给优化掉(去掉了)只有第一此执行load才真正执行了,后续都只cmp,不load(相当于是复用寄存器中load过的值)
因此对于这个处理方式是让编译器针对这个场景暂停优化:使用volatile修饰的变量,此时编译器就会禁止上述优化,能够保证每次都是从内存重新读取数据。即

import java.util.Scanner;

public class ThreadDemo14 {
    volatile public static int flag = 0;// 通过volatile解决编译器优化问题

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
           while (flag == 0) {
               // 空着
           }
            System.out.println("循环结束!t1结束!");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            flag = scanner.nextInt();
        });

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

加上volatile关键字后,此时编译器就能够保证每次都是重新从内存读取flag变量的值。此时t2修改flag,t1就可以立即感知到了,t1就可以正确退出了。
注意:

volatile不保证原子性,volatile实用的场景,是一个线程读,一个线程写的情况。
synchronized则是多个线程的写

5、指令重排序

volatile还有一个效果,就是禁止指令重排序。指令重排序,也是编译器优化的策略。调整代码执行的顺序,让程序变得更高效。前提也是保证整体逻辑不变。
JavaEE--多线程(续)安全问题_第19张图片
如果是单线程环境,此处就可以进行指令重排序。1指令肯定是先执行,2和3谁先执行,谁后执行,都可以。
但是多线程下:
假设t1按照1 3 2的顺序执行,当t1执行完1 3后,即将执行2的时候:
t2开始执行,由于t1的3已经执行过了,这个引用已经非空了,t2就尝试调用s.learn(),但是t1还没调用2(即还没初始化过),此时的learn会变成啥样,不知道了,就很可能产生bug。

六、解决线程安全问题

多线程的初心:进行并发编程,更好的利用多核CPU,要想解决线程不安全,需要从原因入手。也就是如何让count++变成原子。–加锁
锁的核心操作有两个:
(1)加锁
(2)解锁
一旦某个线程加锁了之后,其他线程也想加锁,就不能直接上,需要阻塞等待一直等到拿到锁的线程释放锁了为止。
JavaEE--多线程(续)安全问题_第20张图片

线程调度是抢占式执行的,当1号老铁释放锁后,等待2号和3号谁能抢先一步拿到锁进而成功加锁是不确定的。

1、加锁

Java中我们用synachronized这个关键字来实现加锁效果。

class Counter {
    private int count;

    // 加锁方法一
    /*public void add() {
        synchronized (this) { // this相当于conter
            count++;
        }
    }*/

    // 加锁方法二
    synchronized public void add() {
        count++;
    }

    public int get() {
        return count;
    }
}

这里用两种方法实现加锁操作。
锁有两个核心操作,加锁和解锁:
(1)进入synachronized修饰的代码块的时候,就会触发加锁。
(2)出了synachronized代码块,就会触发解锁。
我们再看这个代码:

JavaEE--多线程(续)安全问题_第21张图片
1、如果两个对象针对同一个对象加锁,此时就会出现“锁竞争”(一个线程先拿到了锁,另一个线程阻塞等待)。
2、如果两个线程,针对不同对象加锁,此时不会存在锁竞争,各自获取各自的锁即可。
3、( )里的锁对象,可以是写作任意一个Object对象(内置类型不行)此处我们这个代码中的this,相当于:Counter counter = new Counter()中的counter对象。
我们再说多个线程是否是针对同一个锁对象进行竞争。

// 创建两个线程,两个线程分别对这个counter自增5w次
        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();
            }
        });

在上述代码中,这俩对象实在竞争同一个锁对象(counter),此时就会产生锁竞争(t1拿到锁,t2就得阻塞)。此时就可以保证++操作就是原子的,不受影响了。
如图所示:
JavaEE--多线程(续)安全问题_第22张图片

2、和join操作区别

加锁和join操作没有什么练习,join只是让两个线程完整的进行串行,加锁,是两个线程的某个小部分串行了,大部分都是并发的。

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();
            }
        });

还是看上面这个代码,一个线程的工作大概是这些:
1、创建i
2、判定i < 50000
3、调用add
4、count++
5、add返回
6、i ++
这些步骤中,只有4是串行的,12356两个线程仍然是并发的。因而加锁就是在保证线程安全的前提下,同时让代码跑的更快一些,更好的利用下多核cpu。
JavaEE--多线程(续)安全问题_第23张图片
事实上,无论如何,加锁都可能导致阻塞,代码阻塞,对于程序的效率肯定还是会有影响的。此处虽然是加锁了,比不加锁要慢点,但是依然比串行要更快,同时比不加锁算的更准。

synchronized public void add() {
        count++;
    }

这种方法是使用synchronized修饰,此时就相当于以this为锁对象。

synachronized public static void test() {
}

如果synachronized修饰方法(static)此时就不是给this加锁了,而是给类对象加锁。

private Object locker = new Object();
    
    public void add() {
        synchronized (locker) {
            count++;
        }
    }

JavaEE--多线程(续)安全问题_第24张图片
这里随便写啥都行,只要是个Object的实例即可(内置类型不行)

3、总结

牢记!

1、如果多个线程尝试对同一个锁对象加锁,此时就会产生锁竞争,针对不同对象加锁,就不会有锁竞争。
2、既然使用了锁,目的就是为了保证线程安全,使用不同锁对象,没有产生竞争也就难以保证刚才说的原子性了。


多线程仍然还有很多内容有待学习,多线程系列也会持续更新。最后我们的花花公主压轴
JavaEE--多线程(续)安全问题_第25张图片

你可能感兴趣的:(java-ee,java,jvm)