JavaEE-多线程初阶2

✏️作者:银河罐头
📋系列专栏:JavaEE

🌲“种一棵树最好的时间是十年前,其次是现在”

Thread类及常见方法

获取当前线程引用

public static Thread currentThread();

返回当前线程对象的引用

休眠当前线程

public static void sleep(long millis) throws InterruptedException

本质上就是这个线程不参与调度了,不去CPU上执行了

image-20221203211022235

PCB是使用链表来组织的,实际情况并不是一个简单的链表,是一系列以链表为核心的数据结构。

一旦线程进入阻塞状态,对应PCB就进入阻塞队列了,就暂时无法参与调度了。

  • 比如调用sleep(1000),那么线程就要在阻塞队列待1000ms,当这个PCB回到了就绪队列,会被立即调度吗?

其实不是,实际上考虑到调度的开销,对应的线程是无法在唤醒之后立即被调度的,实际上的时间间隔大概率要大于1000ms.

挂起(hung up)就是阻塞(block)

线程的状态

线程的所有状态

  • 1.NEW 创建了Thread对象,但是还没调用start(内核里还没创建对应PCB)

  • 2.TERMINATED 表示内核里的PCB已经执行完毕了,但是Thread对象还在

  • 3.RUNNABLE 可运行的

    a)正在CPU上执行的

    b)在就绪队列里,随时可以去CPU上执行的

  • 4.WAITING

  • 5.TIMED_WAITING

  • 6.BLOCKED

4,5,6都是阻塞,都表示线程PCB在阻塞队列中,这几个状态是不同原因的阻塞

线程的状态是一个枚举类型 Thread.State

public class ThreadState {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
       }
   }
}

线程状态

线程的状态转化

image-20221203215929103

WAITING

Scanner这里的阻塞是因为等待IO的, 等待IO也会进行一些线程操作,内部可能会涉及到锁操作或者wait之类的操作。

读写文件,读写控制台,读写网络…都可能会造成阻塞

public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            for(int i = 0;i < 1000000;i++){

            }
        });
        // 在启动之前,获取 t 的状态,就是 NEW 状态
        System.out.println("start 之前:" + t.getState());
        t.start();
        System.out.println("t 执行中的状态:"+ t.getState());
        t.join();
        //线程执行完毕之后,就是 TERMINATED 状态
        System.out.println("t 结束之后:" + t.getState());
    }

一旦内核里的PCB消亡了,此时代码中的t对象就没啥用了。之所以存在,是迫不得已。Java中对象的生命周期自有其规则,这个生命周期和内核中线程并非完全一致。内核的线程释放的时候,无法保证Java代码中t对象也立即释放。因此,势必会存在,内核中PCB没了,但是代码中t还存在这样的情况。因此就需要通过特定的状态,来把t对象标识为"无效"。

一个线程只能start一次。

//输出结果:
start 之前:NEW
t 执行中的状态:RUNNABLE
t 结束之后:TERMINATED

之所以此处能看到RUNNABLE,主要是因为线程run里没有写sleep之类的方法

image-20221204145400187

image-20221204145830999

通过这里的循环获取,就可以看到这里的交替状态,当前获取到的状态完全取决于系统的调度操作,获取状态的这一瞬间 t 线程是正在执行还是正在 sleep

多线程的意义

多线程最核心的地方:抢占式执行,随机调度

多线程的意义是?

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

程序分成

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

IO密集,涉及到读写文件,读写控制台,读写网络

这种衡量执行时间的代码,运行的久一点,误差越小,

线程调度自身也是有时间开销的,运算的任务量越大,线程调度的开销相比之下就非常不明显了,从而就可以忽略不计

public static void main(String[] args) {
        //serial();
        concurrency();
    }
    //串行执行,一个线程完成
    public static void serial(){
        //为了衡量代码的执行速度,加上个计时的操作
        //currentTimeMillis获取到当前系统 ms 级的时间戳
        long beg = System.currentTimeMillis();
        long a = 0;
        for(long i = 0;i < 100_0000_0000L;i++){
            a++;
        }
        long b = 0;
        for(long i = 0;i < 100_0000_0000L;i++){
            b++;
        }
        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - beg) + " ms");
    }

    public static void concurrency(){
        long beg = System.currentTimeMillis();
        Thread t1 = new Thread(()->{
            long a = 0;
            for(long i = 0;i < 100_0000_0000L;i++){
                a++;
            }
        });
        Thread t2 = new Thread(()->{
            long b = 0;
            for(long i = 0;i < 100_0000_0000L;i++){
                b++;
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("并发执行时间:" + (end - beg) + " ms");
    }

image-20221204162234345

多线程更快,多线程可以更充分利用多核心CPU的资源

这个例子中单线程到多线程时间为什么不是正好缩短一半?

不确定t1和t2是在两个CPU上并行执行的还是并发执行的。

实际上,t1和t2在执行过程中会经历很多次调度,这些调度有些是并行执行(在2个核心上),有些是并发执行的(1个核心上)。

到底是多少次并行,多少次并发,不好预估,取决于系统的配置,也取决于当前程序的运行环境。(系统同一时刻跑了很多程序,并发的概率更大,很多程序来抢CPU)

另一方面,线程调度自身也是有时间消耗的。

梳理一遍上述代码的执行逻辑:main线程先调用t1.start,启动t1开始计算t1的同时main再启动t2.start。启动t2的同时t1仍然在继续计算。同时main线程进入t1.join,此时main线程阻塞等待,t1,t2还是在继续执行。等t1执行完了,main线程从t1.join返回再执行t2.join,main线程等待t2,t2执行完了,main线程从t2.join返回,继续执行计时操作。

  • 关于join的理解,比如这里是main线程里调用t1.join(),就是main线程阻塞等待t1结束

不是说多线程一定能提高效率

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

2.当前核心是否空闲(如果CPU这些核心已经都满载了,这个时候启动多线程也没用)

多线程带来的的风险-线程安全

造成线程安全这种问题的原因是多线程的抢占式执行,带来的随机性。从单线程到多线程,代码执行顺序的可能性从一种情况变成了无数种情况。所以就需要保证在这无数种线程调度顺序的情况下,代码的执行结果都是正确的。只要有一种情况代码结果不正确,就被视为线程不安全。

代码示例

class Counter{
    public int count = 0;
    public void add(){
        count++;
    }
}
public class ThreadDemo {
    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();
        }
        System.out.println("count = " + counter.count);
    }
}
//三次运行结果:
count = 53782
count = 55883
count = 68803

预期结果是10w,而实际结果不是10w,并且每次运行的结果都不一样,程序出现bug了!

++ 操作本质上分为三步:

1.先把内存的值读取到CPU的寄存器中 load

2.把CPU寄存器的值进行 + 1 运算 add

3.把得到的结果写回到内存中 save

//这3个操作就是CPU上执行的3个指令。指令是机器语言

如果是两个线程并发执行count ++,此时就相当于两组 load add save 进行执行,此时不同的线程调度顺序就可能会产生一些结果上的差异。

线程一些可能的调度顺序:

image-20221204195705675

其中情况1,2结果是正确的,其他情况都是有问题的

image-20221204200223216

比如情况3

image-20221204200553634

经历2次自增之后count结果还是1

其实这里就和 事务 读未提交 read uncomitted 是一样的,相当于t1读到的是一个t2未提交的脏数据,于是出现了脏读问题

这里的多线程和并发事务本质上都是"并发编程"问题,并发处理事务,底层是基于多线程这样的方式来实现的

image-20221204201422905

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

image-20221204201851396

由于线程的抢占式执行,导致当前执行到任意一个指令的时候线程都可能被调度走,CPU让别的线程来执行。

CPU里有个重要的组成部分,寄存器。寄存器也能存数据,空间更小,访问速度更快,CPU进行的运算都是针对寄存器的数据进行的。

CPU里的寄存器有很多种,

有的是通用寄存器(用来参与运算的) EAX , EBX , ECX…

有的是专用寄存器(有特定功能的) EBP ESP EIP…

保存上下文的,用PCB里的内存把当前所有的寄存器都给保存起来。

机器指令就是汇编,机器指令就是直接在CPU上运行的,势必要经常操作寄存器

当前这个代码是否有可能结果正好是10w?有可能,概率很小

假设2个线程每次调度的顺序都是情况1,2

当前结果一定大于5w?

实际运行基本都是大于5w的,但是也不一定

还可能出现t1自增1次,t2自增2次,(情况5)最终还是增1这种

线程不安全的原因

  • 1.(根本原因)抢占式执行,随机调度
  • 2.代码结构:多个线程修改同一个变量

一个线程修改一个变量,没事;

多个线程读取同一个变量,没事;

多个线程修改多个不同的变量,没事。

因此可以通过修改代码的结构来规避这个问题,这种调整不一定都是能使用的,代码结构也是源于需求的。调整代码结构是个方案,但是不是一个普适性特别高的方案

String是不可变对象

不可变对象,天然就是线程安全的。

像有些编程语言,比如erlang,语法里没有"变量"这个概念,所有的"变量"都是不可变的,这样的语言更适合并发编程,出现线程安全的概率大大降低了

  • 3.原子性

如果修改对象是原子的,那就罢了

如果是非原子的,出现问题的概率就非常高了。

原子:不可拆分的最小单位。

count ++ 这里可以拆分成 load add save 三个操作,这三个操作中的每个操作都是原子的,单个指令是无法再进一步拆分了。

如果++操作是原子的,那么线程安全问题就迎刃而解了(出问题的本质是"脏读",t1修改的结果还没提交,t2就读了)

针对线程安全问题,如何解决?最主要的手段就是从这个原子性入手,把这个非原子的操作变成原子的,加锁。

  • 4.内存可见性问题

如果是一个线程读,一个线程改呢?也可能出问题,可能出现此处读的结果不太符合预期。

  • 5.指令重排序

本质上是编译器优化出bug了,编译器在保持逻辑不变的情况下,调整代码的执行顺序,从而加快代码的执行效率

上述分析出的是5个典型的原因,不是全部。一个代码究竟是线程安全还是不安全,都得具体情况具体分析,难以一概而论。

如果一个代码踩中了上面的原因,也可能线程安全;如果一个代码没踩中上面的原因,也可能线程不安全。

原则是多线程运行代码不出bug就是安全的。

在阅读代码的时候,脑子里分析出所有可能执行的情况,并一一判定里面是否有问题

重排序指的是单个线程里,顺序发生调整。

synchronized 关键字

synchronized 的特性

1)互斥

如何从原子性入手来解决线程安全问题,通过加锁。

synchronized public void add(){
        count++;
    }
//synchronized 这是一个关键字,表示加锁

加了synchronized之后,进入方法会加锁,出了方法会解锁。

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

image-20221205143418519

t2加锁没加成,一直阻塞等待到 t1 unlock了之后才继续执行。

加锁,说是保证原子性,其实不是说让这里的3次操作一次完成,也不是这3步操作过程中不调度,而是让其他也想操作的线程阻塞等待了。

加锁本质上是把并发变成了串行。

操作系统中的基本设定,系统里的锁"不可剥夺"特性,一旦一个线程获取到锁,除非他主动释放,否则无法强占。

synchronized 的行为就是阻塞等待,一直等下去。 Java中还有一种锁, ReentrantLock 这个锁,获取不到就放弃

一旦加锁之后,代码的速度是大打折扣的。算的快前提是算得准。

虽然加锁之后算的慢了,但是还是比单线程快,加锁只是针对count++ 加锁了,除了 count++ 之外还有for循环的代码,for循环代码是可以并发执行的,只是count++ 串行执行了。一个任务中,一部分并发,一部分串行,仍然是要比所有代码都串行要更快的。

package Thread;
import java.util.concurrent.CountDownLatch;
class Counter{
    public int count = 0;
    synchronized public void add(){
        count++;
    }
}
public class ThreadDemo13 {
    public static void main(String[] args) {
        Counter counter = new Counter();
        //搞两个线程,两个线程分别针对 counter 来调用 5w 次的 add 方法
        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();
        }
        System.out.println("count = " + counter.count);
    }
}
//结果:
count = 100000
  • synchronized使用方法

1.修饰方法

1)修饰普通方法。锁对象就是this

2)修饰静态方法。锁对象就是类对象(Counter.class)

2.修饰代码块。显式/手动指定锁对象

加锁要明确是针对哪个对象加锁。

如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争/锁冲突)。

如果两个线程针对不同对象加锁,不会阻塞等待(不会锁竞争/锁冲突)。

两个线程,一个线程加锁,一个线程不加锁,这种情况没有锁竞争。

image-20221212122238491

线程 对 对象加锁

举个例子,张三的npy是李四,相当于张三对李四加锁了,如果王五也想和李四处对象(王五也想对李四加锁)。就会出现锁竞争/锁冲突。因为张三已经对李四加锁了,所以王五就只能阻塞等待。不过赵六刚好单身,王五可以对赵六加锁。张三和王五对不同对象加锁,不会发生阻塞等待。

(两男争一女会出现锁竞争)

没有锁竞争=>抢占式执行=>很有可能出现线程安全问题

image-20221212180855343

image-20221212192133498

问:构造方法可以使用synchronized关键字修饰吗?

不能❌

synchronized关键字作用于方法上,是给当前对象实例/类加锁,而在构造方法上加 synchronized,此时对象实例还没产生;另外构造方法每次都是构造出新的对象,不存在多个线程同时读写同一对象中的属性的问题,所以不需要同步。

monitor lock 监视器锁

JVM给 synchronized 起的名字,代码报异常可能会见到 monitor lock

2)可重入

image-20221212193047032

因为在Java里这种代码是很容易出现的,为了避免不小心就死锁,Java就把 synchronized 设定为可重入的了。但是 C++ , Python , 操作系统原生的锁,都是不可重入的。

就是在锁对象里记录一下,当前锁是哪个线程持有的。如果加锁线程和持有线程是同一个,就直接放过,否则就阻塞。

上面只是死锁的一种情况,还有别的情况。

Java 标准库中的线程安全类

如果多个线程操作同一个集合类,就需要考虑线程安全的问题。

ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder

上述这些都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.

Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer

上述这些已经内置了 synchronized 加锁,相对来说更安全一点。

StringBuffer 的核心方法都带有 synchronized .

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的,比如String

死锁

死锁的几种情况

1.一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会出现死锁。Java中synchronized和ReentrantLock都是可重入锁。

2.两个线程,两把锁,t1和t2各自先针对锁A和锁B加锁,再尝试获取对方的锁。

举个栗子:家钥匙锁车里了,车钥匙锁家里了。

public class ThreadDemo {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                    //sleep是为了确保两个线程先把第一把锁拿到(线程是抢占式执行的)
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1 把 locker1 和 locker2 都拿到了");
                }
            }

        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker1){
                    System.out.println("t2 把 locker2 和 locker1 都拿到了");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

image-20221212210355985

程序结果没有输出,死锁了。

image-20221212211754984

image-20221212211808217

针对这样的死锁问题,可以借助 jconsole 这样的工具来进行定位,看线程的状态和调用栈就可以分析出程序在哪里死锁了。

3.多个线程 多把锁(相当于2的一般情况)

经典案例:哲学家就餐问题

image-20221212215101681

5个哲学家围着桌子吃意大利面,只有5只筷子。每个哲学家有两种状态,1.思考人生(相当于线程阻塞的状态) 2.拿起筷子吃面条(相当于线程获取到锁然后进行一些计算)。由于操作系统随机调度,这5个哲学家随时有可能想吃面也随时有可能在思考人生,要想吃面就得拿起左手和右手的两根筷子。

假如出现了极端情况,同一时刻哲学家同时拿起左手的筷子,这时哲学家拿不起右手的筷子,都要等待右边的哲学家把筷子放下,出现了死锁。

死锁的4个必要条件

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。

前3个条件都是锁的基本特性(对于synchronized这把锁来说无法改变)

条件4循环等待是这4个条件里唯一一个和代码结构相关的,也是程序员可以控制的。

世界上的锁不是只有synchronized,还会存在一些其他情况的锁,可能和上述1,2,3条件还有变数。

如何避免死锁

打破必要条件即可,突破口是循环等待。

解决办法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,任意线程加多把锁的时候都让线程遵守上述规则,此时循环等待自然破除。

以哲学家用餐为例如何破除死锁:

image-20221213114519760

//上面例子
//约定两个线程先拿编号小的后拿编号大的。(先拿locker1,后拿locker2)
public class ThreadDemo {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1 把 locker1 和 locker2 都拿到了");
                }
            }

        });
        Thread t2 = new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t2 把 locker2 和 locker1 都拿到了");
                }
            }
        });
        t1.start();
        t2.start();
    }
}
//输出结果:
t1 把 locker1 和 locker2 都拿到了
t2 把 locker2 和 locker1 都拿到了
  • 7
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值