线程的一些事(2)

在java中,线程的终止,是一种"软性"操作,必须要对应的线程配合,才能把终止落实下去

然而,系统原生的api其实还提供了,强制终止线程的操作,无论线程执行到哪,都能强行把这个线程干掉。

这样的操作Java的api中没有提供的,上述的做法弊大于利,强行取结束一个线程,很可能线程执行到一半,会出现一些残留的临时性质的"错误"数据。

java 复制代码
public class ThreadDemo12 {
    public static void main(String[] args) {
        boolean isQuit = false;
        Thread t = new Thread(() -> {
            while (!isQuit){
                System.out.println("我是一个线程,工作中!!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            //当前是死循环,给了个错误指示
          /*  System.out.println("线程工作完毕!");*/
        });
        t.start();

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("让t线程结束!");
        isQuit = true;
    }
}

我们将变量isQuit作为main方法中的局部变量。

弹出了警告,这就涉及到lambda表达式的变量捕获了,当前捕获的变量是isQuit所以对于isQuit来说,它要么加上final,要么不去进行修改。

isQuit是局部变量的时候,是属于main方法的栈帧中,但是Thread lambda是又自己独立的栈帧的,这两个栈帧的生命周期是不一致的

这就可能导致main方法执行完了,栈帧就销毁了,同时Thread的栈帧还在,还想继续使用isQuit--

在java中,变量捕获的本质就是传参,就是让lambda表达式在自己的栈帧创建一个新的isQuit并把外面的isQuit的值拷贝过来(为了避免isQuit的值不同步,java就不让isQuit来进行修改)

等待线程

多个线程的执行顺序是随机的,虽然线程的调度是无序的,但是可以通过一些api来影响线程执行的顺序。

join就可以,

java 复制代码
public class ThreadDemo14 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("我是一个线程,正在工作中...");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程执行结束");
        });

        t.start();

       /* Thread.sleep(5000);*/
        //这个操作就是线程等待
        t.join();
        System.out.println("这是主线程,期望这个日志在 t 结束后打印");
    }
}

这种方法比sleep方法要好很多,毕竟谁也不知道t线程啥时候结束,用join可以让线程等 t 线程结束后再执行,这时候main线程的状态就是"阻塞"状态了。

Thread类基本的使用

1.启动线程 start方法

理解 run 和 start 区别

2.终止线程 核心让run方法能够快速结束

非常依赖 run 内部的代码逻辑

Thread isInterrupted(判定标志位)/interrupt(设置标志位)

如果提前唤醒sleep会清楚标志位

3.等待线程 join 让一个线程等待另一个线程结束

线程之间的顺序我们无法控制,但我们可以控制结束顺序

获取线程引用

Thread.currentThread()获取到当前线程的 引用(Thread 的引用)

如果是继承Thread,直接使用 this 拿到线程实例

如果不是则需要使用 Thread.currentThread();

线程的状态

就绪:这个线程随时可以去 cpu 上执行

阻塞:这个线程暂时不方便去cpu上执行

java中线程又以下几种状态:

1.NEW Thread 对象创建好了,但是还没有调用 start 方法在系统中创建线程.
2.TERMINATED Thread 对象仍然存在,但是系统内部的线程已经执行完毕了

3.RUNNABLE 就绪状态.表示这个线程正在 cpu 上执行,或者准备就绪随时可以去 cpu 上执行4.TIMED WAITING 指定时间的阻塞, 就在到达一定时间之后自动解除阻塞.使用 sleep 会进入这个状态.使用带有超时时间的join也会
5.WAITING 不带时间的阻塞 (死等),必须要满足一定的条件,才会解除阻塞

6.BLOCKED 由于锁竞争,引起的阻塞,

线程安全问题,来看下面的一段代码

java 复制代码
public class ThreadDemo19 {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
       //随便创建个对象都行
      /*  Object locker = new Object();*/

        //创建两个线程,每个线程都针对上述 count 变量循环自增 5w次
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
              /*  synchronized(locker) {
                    count++;
            }*/
                count++;
            }
        });
        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
              /*  synchronized(locker) {
                    count++;
                }*/
                count++;
            }
        });
        t1.start();
        t2.start();

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

        //打印count结果
        System.out.println("count = " + count);
    }
}

我们发现这个结果是错的,我们计算的结果应该是100000.

这就涉及到线程安全问题了

count++是由三个指令构成的
1.load 从内存中读取数据到cpu寄存器
2.add 把寄存器中的值 + 1
3.save 把寄存器的值写回到内存中

对于单个线程是没有这种问题的,但是对于多线程就会冒出来问题

我们发现预期是进行两次count++后返回的count为2,但是因为两个线程在读取时出现了问题,第二个线程读取的数据是还未进行更新的数据,这就导致出现了错误。

如果是这样的顺序自然没有问题了

我们需要的进行顺序应该时等第一个线程save后第二个线程再进行load。

本质时因为线程之间的调度时无序的时抢占式执行

这就不得不提到String这个"不可变对象"了

1.方便JVM进行缓存(放到字符串常量池中)
2.hash值固定
3.线程安全的

线程不安全原因

1.根本原因 操作系统上的线程时"抢占式执行""随即调度" => 线程之间执行顺序带来了很多变数

2.代码结构 代码中多个线程,同时修改同一个变量

1.一个线程修改一个变量

2.多个线程读取同一个变量

3.多个线程修改不同变量

这些都不会有事

3.直接原因 上述的线程修改操作本身不是'原子的'

4.内存可见性问题

5.指令重排序问题

对于3这个问题我们可以找办法来解决

1.对于抢占式执行修改,这是无法改变的事

2.对代码结构进行调整,这是个办法,但在有些情况下也是不适用的

3.可以通过特殊手段将着三个指令打包为一个"整体",我们可以对其进行加锁

加锁

目的:把三个操作,打包成一个原子操作

进行加锁的时候需要先准备好锁对象,一个线程针对一个锁对象加锁后,当其他线程对锁对象进行加锁,则会产生阻塞(BLOCKED)(锁冲突/锁竞争),一直到前一个线程释放锁为止

要加锁得用到synchronized。

进入()就会加锁(lock),出了{ }就会解锁(unlock),synchronnized 是调用系统的 api 进行加锁,系统api本质上是靠 cpu 上特定指令完成加锁

当t1加锁后,在没解锁的情况下,t2再想进行加锁就会出现阻塞

在t1没有解锁的情况下,即使t1被调度出cpu,t2也还是在阻塞

即使这样会影响到执行效率,但也比串行要快不少。

我们只是对count加锁使得count串行,但for循环还是可以进行"并发"执行的

加锁之后结果就正确了。

对于对象的话只要不是同一个对象就不会有竞争这一说。

1.如果一个线程加锁,一个不加,是不会出现锁竞争的

2.如果两个线程,针对不同的对象加锁,也还是会存在线程安全问题

把count放到一个 Test t 对象中,通过add来修改锁对象的时候可以写作this

相当于给this加锁(锁对象 this)

对于静态方法的话相当于给类对象加锁

我们可不可以加两个锁呢?

是否会打印hello?

为啥会打印成功?不应该出现锁冲突吗?

当前由于是同一个线程,此时锁对象,就知道了第二次加锁的线程,就是持有锁的线程,第二次操作,就可以直接放行不会出现阻塞。

这个特性被称为"可重入"

一旦上述的代码出现了阻塞,就称为"死锁"

可重入锁就是为了防止我们在"不小心"中引入的问题

当我们在第一次加锁的时候,计数器会进行加一操作,当第二次进行加锁的时候,大仙加锁的线程和持有锁线程是一个线程,这个时候就会加锁成功,并且计数器加一。

等到了计数器为0的时候才是真正的解锁了,对于可重入锁来说:

1.当前这个锁是被哪个线程持有的

2.加锁次数的计数器

计数器可以帮助线程清楚的记录有几个锁。

加锁能够解决线程安全问题,但同时也引入了一个新的问题就是死锁。

死锁的三种典型场景

1.一个线程一把锁

如果锁是不可重入锁,并且对一个线程对这把锁进行加锁两次

2.两个线程,两把锁

线程 1 获得 锁A

线程 2 获得 锁B

接下来 1 尝试获取B, 2 尝试获取 A就同样出现死锁了!!!

一旦出现"死锁",线程就"卡住了"无法继续工作

java 复制代码
public class ThreadDemo22 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A){
                //sleep一下是给t2时间让t2也能拿到B
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //尝试获取B,并没有释放A
                synchronized (B){
                    System.out.println("t1拿到两把锁");
                }
            }

        });

        Thread t2 = new Thread(() -> {
            synchronized (B){
                //sllep一下,是给t1时间,让t1能拿到A
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //尝试获取A并没有获取B
                synchronized (A){
                    System.out.println("t2拿到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

就像这样。

3.N个线程M把锁

哲学家就餐

解决死锁问题的方案

产生死锁的四个必要条件

1.互斥使用,获取锁的过程是互斥的,一个线程拿到了这把锁,另一个线程也想获取,就需要阻塞等待。
2.不可抢占,一个线程拿到了锁之后,只能主动解锁,不能让别的线程强行把锁抢走。
3.请求保持,一个线程拿到了锁 A 之后,在持有A的前提下,尝试获取B
4.循环等待,环路等待

由于四个都是必要条件,所以只要破环一个就解决问题了。

1,2.锁最为基本的特性

3.代码结构要看实际需求

4.代码结构的,最为容易破坏

指定一定的规则,就可以有效的避免循环等待

1.引入额外的筷子

2.去掉一个线程

3.引入计数器,限制最多同时所少人吃饭

4.引入加锁顺序的规则

内存可见性引起的线程安全问题

java 复制代码
public class ThreadDemo23 {
    private 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(() -> {
            System.out.println("请输入flag的值:");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
           t1.start();
           t2.start();
    }
}

运行代码发现,并没有我们想象的打印t1线程结束,而是直接不动了。

在这个过程中有两个关键的点

1.load 操作执行的结果,每次都是一样的(要想输入,过几秒才能输入,在这几秒都不知道循环都已经执行了上百亿次了)
2.load 操作开销远远超过 条件跳转

访问寄存器的操作速度,远远超过访问内存

由于load开销大,并且load的结果又一直没有变化,所以jvm就会怀疑load操作有必要存在的必要吗?

此时jvm就可能做出代码优化,把上述load操作,给优化掉(只有前几次进行load,后续发现,load反正都一样,静态分析代码,也没看到哪里改了flag,因此就把load操作,干掉了),干掉之后,就相当于不再重复读内存直接使用寄存器之前"缓存"的值,大幅度的提高循环的执行速度

多线程的情况下很容易出现误判,这里相当于 t2 修改了内存,但是 t1 没有看到这个内存优化,就称为"内存可见性"问题

我们发现在刚刚的代码加上sleep就会执行成功,即使sleep时间有多小。 因为不加sleep一秒钟可能循环上百亿次,load开销非常大,优化迫切程度就更高。

加了sleep,一秒钟可能循环的次数就可能变为1000次,这样load开销相对来说就小了,所以优化迫切程度就想对来说就低了。

内存可见性问题,其实是个高度依赖编译器优化的问题,啥时候触发这个问题,都不知道

所以干脆希望不要出现内存可见性问题,将上述优化给关闭了

这就要使用关键字 volatile 来对上述的优化进行强制的关闭(虽然开销大了,效率低了。但是数据准去性/逻辑正确性提高了)。

volatile 关键字

核心功能就是保证内存可见性(另一个功能进制指令重排序)

在上述的代码中,编译器发现,每次循环都要读取内存,开销太大,于是就把读取内存操作优化成读取寄存器操作,提高效率

在JMM模型的表述下

在上述代码中,编译器发现,每次循环都要读取"主内存",就会把数据从"主内存"中复制到"工作内存"中,后续每次都是读取"工作内存"。

相关推荐
NE_STOP3 分钟前
SpringBoot--简单入门
java·spring
hqxstudying30 分钟前
Java创建型模式---原型模式
java·开发语言·设计模式·代码规范
Dcs1 小时前
VSCode等多款主流 IDE 爆出安全漏洞!插件“伪装认证”可执行恶意命令!
java
保持学习ing1 小时前
day1--项目搭建and内容管理模块
java·数据库·后端·docker·虚拟机
京东云开发者1 小时前
Java的SPI机制详解
java
超级小忍1 小时前
服务端向客户端主动推送数据的几种方法(Spring Boot 环境)
java·spring boot·后端
程序无bug2 小时前
Spring IoC注解式开发无敌详细(细节丰富)
java·后端
小莫分享2 小时前
Java Lombok 入门
java
程序无bug2 小时前
Spring 对于事务上的应用的详细说明
java·后端
食亨技术团队2 小时前
被忽略的 SAAS 生命线:操作日志有多重要
java·后端