当前位置:首页 » 《休闲阅读》 » 正文

【JavaEE初阶】落霞与孤鹜齐飞,秋水共长天一色 - (重点)线程

9 人参与  2024年12月06日 08:01  分类 : 《休闲阅读》  评论

点击全文阅读



在这里插入图片描述

本篇博客给大家带来的是线程的知识点, 由于时间有限, 分三天来写, 本篇为线程第二篇.
?文章专栏: JavaEE初阶
?若有问题 评论区见
❤欢迎大家点赞 评论 收藏 分享
如果你不知道分享给谁,那就分享给薯条.
你们的支持是我不断创作的动力 .

王子,公主请阅

1. Thread类的常见方法1. 等待线程 - join()1.2 获取当前线程引用1.3 休眠当前线程 2. 线程的状态2.1 线程的所有状态 3. 多线程带来的的风险-线程安全 (重点)3.1 观察线程安全3.2 线程安全的概念3.3 线程不安全的原因3.3.1 操作系统中,线程的调度顺序是随机的3.3.2 修改数据共享3.3.3 修改操作不是原子性的3.3.4 内存可见性3.3.5 指令重排序 4. synchronized关键字 - 监视锁monitor lock4.1 synchronized的特性4.1.1 可重入4.1.2 互斥4.1.3 多个线程,N把锁.即哲学家就餐问题 (重点)4.1.4 死锁的成因(重点) 4.2 synchronized 使用示例4.2.1 修饰代码块4.2.2 synchronized 修饰普通方法4.2.3 修饰静态方法 4.3 Java 标准库中的线程安全类 5. volatile 关键字5.1 volatile能保证内存可见性5.2 volatile不保证原子性 6. wait和notify6.1 wait()方法6.2 notify()方法6.3 notifyAll()方法6.4 wait 和 sleep的区别(面试题)

1. Thread类的常见方法

1. 等待线程 - join()

有时,我们需要等待⼀个线程完成它的工作后,才能进行自己的下⼀步工作。例如,张三只有等李四转账成功,才决定是否存钱,这时我们需要⼀个方法明确等待线程的结束。

t.join(); 表示等待 t 线程结束

//join 实现线程等待效果public class Demo10 {    public static void main(String[] args) throws InterruptedException {        Thread t = new Thread(() -> {            for (int i = 0; i < 5; i++) {                System.out.println("t 线程工作中");                try {                    Thread.sleep(1000);                } catch (InterruptedException e) {                    throw new RuntimeException(e);                }            }        });        t.start();        //让主线程来等待t线程执行结束.        //一旦调用join, 主线程就会触发阻塞, 此时t线程可以趁机完成后续的工作        //一直阻塞到 t 执行结束完毕了, join 才会解除阻塞, 才能继续执行.        System.out.println("join 等待开始.");        t.join(); //主线程等待 t 线程结束        System.out.println("join 等待结束.");    }}

在这里插入图片描述

1.2 获取当前线程引用

在这里插入图片描述

public class test6 {    public static void main(String[] args) {        Thread t = Thread.currentThread();        System.out.println(t.getName());    }}

1.3 休眠当前线程

因为线程的调度是不可控的,所以这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的。

在这里插入图片描述

public class test6 {    public static void main(String[] args) throws InterruptedException {        System.out.println(System.currentTimeMillis());        Thread.sleep(3*1000);        System.out.println(System.currentTimeMillis());    }}

2. 线程的状态

2.1 线程的所有状态

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

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

• NEW: 安排了工作, 还未开始行动. 即Thread对象已经有了, 但start方法还没调用.
• RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 就是之前说的就绪状态.
• TERMINATED: 工作完成了. Thread对象还在, 内核中的线程已经没了.
• BLOCKED: 都表示排队等着其他事情. 由于锁竞争导致的阻塞.
• WAITING: 都表示排队等着其他事情. 由于wait这种没有固定时间的方式产生的阻塞.
• TIMED_WAITING: 表示排队等着其他事情. 由于sleep这种固定时间的方式产生的阻塞.


在这里插入图片描述

把李四、王五找来,还是给他们在安排任务,没让他们行动起来,就是 NEW 状态;
当李四、王五开始去窗口排队,等待服务,就进入到 RUNNABLE 状态。该状态并不表示已经被银行工作人员开始接待,排在队伍中也是属于该状态,即可被服务的状态,是否开始服务,则看调度器的调度;
当李四、王五因为⼀些事情需要去忙,例如需要填写信息、回家取证件、发呆⼀会等等时,进入BLOCKED 、 WATING 、 TIMED_WAITING 状态,至于这些状态的细分,以后再详解;
如果李四、王五已经忙完,为 TERMINATED 状态。
所以,之前我们学过的 isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。

观察 1: 关注 NEW 、 RUNNABLE 、 TERMINATED 状态的转换

public class test6 {    public static void main(String[] args) {        Thread t = new Thread(() -> {            for (int i = 0; i < 1000; i++) {            }        },"李四");        System.out.println(t.getName() + ": " + t.getState());        t.start();        while(t.isAlive()) {            System.out.println(t.getName() + ": " + t.getState());        }        System.out.println(t.getName() + ": " + t.getState());    }}

观察 2: 关注 WAITING 、 BLOCKED 、 TIMED_WAITING 状态的转换

public class test7 {    public static void main(String[] args) {        final Object object = new Object();        Thread t1 = new Thread(() -> {            synchronized(object) {                while(true) {                    try {                        Thread.sleep(1000);                    } catch (InterruptedException e) {                        throw new RuntimeException(e);                    }                }            }        },"t1");        Thread t2 = new Thread(() -> {            synchronized(object) {                System.out.println("嘻嘻嘻");            }        },"t2");        t1.start();        t2.start();    }}

在这里插入图片描述
在这里插入图片描述
使用 jconsole 可以看到 t1 的状态是 TIMED_WAITING , t2 的状态是 BLOCKED

修改上面的代码, 把 t1 中的 sleep 换成 wait, 使用 jconsole 可以看到 t1 的状态是 WAITING

public class test7 {    public static void main(String[] args) {        final Object object = new Object();        Thread t1 = new Thread(() -> {            synchronized(object) {                try {                    object.wait();                } catch (InterruptedException e) {                    throw new RuntimeException(e);                }            }        },"t1");        Thread t2 = new Thread(() -> {            synchronized(object) {                System.out.println("嘻嘻嘻");            }        },"t2");        t1.start();        t2.start();    }}

在这里插入图片描述

结论:
• BLOCKED 表示等待获取锁, WAITING 和 TIMED_WAITING 表示等待其他线程发来通知.
• TIMED_WAITING 线程在等待唤醒,但设置了时限; WAITING 线程在无限等待唤醒.

3. 多线程带来的的风险-线程安全 (重点)

3.1 观察线程安全

public class test7 {    private static int count = 0;    public static void main(String[] args) throws InterruptedException {        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();        //如果没有join方法肯定不行, 线程还没自增完就开始打印了, 很可能打印出来的count是 0;        t1.join();        t2.join();        System.out.println("count: " + count);    }}

上述代码的预期结果为100000, 可无论怎么运行达不到预期结果, 这就是一个线程安全问题.

3.2 线程安全的概念

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

3.3 线程不安全的原因

3.3.1 操作系统中,线程的调度顺序是随机的

这是线程安全问题的罪魁祸首, 随机调度使一个程序在多线程环境下,执行顺序存在很多的变数. 程序员必须保证在任意执行顺序下, 代码都能正常执行.

3.3.2 修改数据共享

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

3.3.3 修改操作不是原子性的

什么是原子性呢?
我们把一段代码想象成一个房间, 每个线程就是要进入房间里的人. 如果没有任何的保护机制, 当A进入房间后, 还没有出来; B是不是也可以进入房间,打断A在房间里的隐私. 这个就是不具备原子性的.
有时也把这个现象叫做同步互斥.
那么其实解决这个问题的关键就是给房间里加上一把锁, A进入房间后,把门锁上自然就不会被打断了. 这样就保证了代码的原子性.
一条 java 语句不一定是原子的,也不一定只是一条指令

比如: 上述代码中的count++, 其实是由CPU中的三条指令来实现的.

1. load 把数据从内存读到CPU寄存器
2. add 把寄存器中的数据+1
3. save 把寄存器中的数据保存到内存中.

在这里插入图片描述

给上面的代码加锁, 保证count++的原子性:

//线程安全问题.public class Demo13 {    private static int count = 0;    public static void main(String[] args) throws InterruptedException {        Object locker = new Object();        Object locker2 = new Object();        Thread t1 = new Thread(() -> {            for (int i = 0; i < 50000; i++) {                // 不调整代码结构, 进行写加锁 也能解决线程安全问题.                //() 中 需要针对同一个对象.                synchronized(locker) {                    count++;                }            }        });        Thread t2 = new Thread(() -> {            for (int i = 0; i < 50000; i++) {                synchronized(locker) {                    count++;                }            }        });        t1.start();        t2.start();        //没有调用join方法肯定是不行的.        //两线程都调用join()方法, 确保 主线程main 等待 t1,t2线程执行结束,再结束.        t1.join();        t2.join();        // 预期结果10W        System.out.println("count: " + count );    }}

当两个对象尝试对一个对象加锁, 此时就会出现"锁冲突"/“锁竞争”. 一旦竞争出现,一个线程能够拿到锁,继续执行代码; 另一个线程拿不到锁,就只能阻塞等待,等待前一个线程释放锁之后,它才有机会拿到锁,继续执行.
这样的规则,本质上是把"并发执行" 变为 “串行执行”
此时就不会出现 “穿插” 的情况了

synchronized除了用于修饰代码块还可用于修饰一个实例方法或者静态方法

class Counter {    public int count;    //第一种简化写法: 本质上就是第二种写法.    synchronized public void increase() {        count++;    }    //第二种写法:    public void increase2() {        synchronized(this) {            count++;        }    }    //第三种写法    //修饰静态方法相当于给类对象加锁.    synchronized public static void increase3() {    }    //第四种写法    public static void increase4() {        synchronized (Counter.class/*类对象*/) {        }    }}public class Demo14 {    public static void main(String[] args) throws InterruptedException {        Counter counter = new Counter();        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.join();        t2.join();        System.out.println("count = "+ counter.count);    }}

3.3.4 内存可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到. 系统编译器为了提高效率做出一些优化导致线程不安全.(详见 5. volatile关键字)

3.3.5 指令重排序

什么是代码重排序?
⼀段代码是这样的:
1. 去前台取下 U 盘
2. 去教室写 10 分钟作业
3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序.
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这⼀点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

4. synchronized关键字 - 监视锁monitor lock

4.1 synchronized的特性

4.1.1 可重入

可重入指的是一个线程连续针对一把锁,加锁两次而不会出现死锁, 满足这个要求的锁就是可重入锁.

理解 “死锁”
一个线程没有释放锁, 然后⼜尝试再次加锁.
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁

for (int i = 0; i < 50000; i++) {            synchronized(object) {                synchronized(object) {                    count++;                }            }        }

在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
解锁的时候计数器递减为 0 的时候, 才真正释放锁. (出了最后一个 “}” 才能被别的线程获取到)

4.1.2 互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

synchronized用的锁是存在Java对象头里的。
在这里插入图片描述
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队


理解 “阻塞等待”.
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试
进行加锁, 就加不上了, 就会阻塞等待, ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程,再来获取到这个锁.
注意:
• 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不⼀定就能获取到锁,而是和 C 重新竞争, 并不遵守先来后到的规则.

synchronized的底层是使用操作系统的mutex lock实现的.

4.1.3 多个线程,N把锁.即哲学家就餐问题 (重点)

五个哲学家(线程)坐在桌前, 桌上只有一碗面条和五只筷子(锁), 五个哲学家都想吃面条:
在这里插入图片描述
如果五个哲学家都想吃面条,他们拿起左边的筷子, 此时会发现右边没有筷子,于是五个哲学家阻塞等待, 出现死锁, 因为等待的过程中哲学家都不会放下左手的筷子.

解决上述死锁的方法: 针对锁进行编号, 约定加锁的时候,先加编号小的,后加编号大的. 所有的线程都必须遵守这个规则.

4.1.4 死锁的成因(重点)

1. 锁是互斥使用的, 线程已经拿到锁1之后, 线程2想要获取锁1 只能阻塞等待.
2. 不可抢占. 当锁1 已经被线程1获取. 线程2想获取锁1不能强行抢过来.
3. 请求保持(代码结构). 一个线程尝试获取多把锁. (先拿到锁1 之后, 再尝试获取锁2的时候, 锁1不会释放.
4. 循环等待(代码结构).

4.2 synchronized 使用示例

synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也必要搭配⼀个具体的对象来使用

4.2.1 修饰代码块

1) 锁任意对象:

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

2) 锁当前对象:

public class test8 {   /* private Object locker = new Object();*/    public void method() {        synchronized (this) {        }    }}

4.2.2 synchronized 修饰普通方法

public class test8 {   public synchronized void methond() {   }}

4.2.3 修饰静态方法

public class test8 {   public synchronized static void methond() {   }}

两个线程竞争同⼀把锁, 才会产⽣阻塞等待.
两个线程分别尝试获取两把不同的锁, 不会产生竞争.

4.3 Java 标准库中的线程安全类

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

线程不安全的类: ArrayList , LinkedList , HashMap , TreeMap , HashSet , TreeSet , StringBuilder

线程安全的类:Vector, HashTable(前面两个不推荐使用) , ConcurrentHashMap , StringBuffer(有一些没涉及修改的方法没加锁,不影响线程安全.) , String.

5. volatile 关键字

5.1 volatile能保证内存可见性

计算机运行的程序/代码,经常要访问数据,这些数据往往会存储在内存中, (比如: 定义一个变量,变量就是在内存中.)
cpu 使用这个变量的时候,就会把这个内存中的数据,先读出来,放到 cpu 的寄存器中再参与运算.(load)

CPU 进行大部分操作,都很快,一旦操作到读/写内存,此时速度一下就降下来了

导致线程不安全的关键 -> 为了解决上述的问题,提高效率,此时编译器,就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器减少读内存的次数,也就可以提高整体程序的效率了.

public class test9 {    private /*volatile*/ static int isQuit = 0;    public static void main(String[] args) {        Thread t1 = new Thread(() -> {           while(isQuit == 0) {               //...           }           System.out.println("t1结束");        });        t1.start();       Thread t2 = new Thread(() -> {           System.out.println("输入isQuit的值:");           Scanner scanner = new Scanner(System.in);           isQuit = scanner.nextInt();       });       t2.start();    }}

上述代码的预期结果是输入非0值,t1线程就要退出.
但是当输入非0值时,此时t1线程并未结束.
在这里插入图片描述
在这里插入图片描述
很明显上述代码存在线程安全问题, 这本质上是由编译器的错误优化引起的.

解决: 用volatile修饰变量isQuit. 告诉编译器不需要优化. (其实不用volatile修饰变量, 在t1线程中用上 sleep 方法也能达到预期结果, 所以并不能够很好的确定什么时候一定会出现这种问题)
总结: 涉及到修改操作的变量用上volatile修饰总是没错的.

5.2 volatile不保证原子性

volatile 和 synchronized 有着本质的别.synchronized 能够保证原子性, volatile 保证的是内存可见性.

public class tes10 {    static class Counter {        volatile public int count = 0;        void increase() {            count++;        }    }    public static void main(String[] args) throws InterruptedException {        final Counter counter = new Counter();        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.join();        t2.join();        System.out.println(counter.count);    }}

如上代码, 删掉synchronized ,利用volatile修饰count, count 达不到预期结果. 说明volatile 不能保证原子性.

6. wait和notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序

篮球场上的每个运动员都是独立的 “执行流” , 可以认为是一个 “线程”.而完成⼀个具体的进攻得分动作, 则需要多个运动员相互配合, 按照⼀定的顺序执行⼀定的动作, 线程1 先 “传球” , 线程2 才能 “扣篮”.
完成这个协调工作, 主要涉及到三个方法
• wait() / wait(long timeout): 让当前线程进入等待状态.
• notify() / notifyAll(): 唤醒在当前对象上等待的线程.
注意: wait, notify, notifyAll 都是 Object 类的方法.

6.1 wait()方法

wait 做的事情:

使当前执行代码的线程进行等待. (把线程放到等待队列中)释放当前的锁满足⼀定条件时被唤醒, 重新尝试获取这个锁.

wait 要搭配 synchronized 来使⽤. 脱离 synchronized 使⽤ wait 会直接抛出异常

wait 结束等待的条件:

其他线程调用该对象的 notify 方法.wait 等待时间超时 (wait 方法提供⼀个带有 timeout 参数的版本, 来指定等待时间).其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
public class Demo19 {    public static void main(String[] args) throws InterruptedException {        Object object = new Object();        synchronized (object) {            System.out.println("wait之前");            //把 wait 要放到 synchronized 里面来调用. 保证确实是拿到锁了的.            object.wait();            System.out.println("wait之后");        }    }}

6.2 notify()方法

notify 方法是唤醒等待的线程.

方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。如果有多个线程等待,则由线程调度器随机挑选出⼀个呈 wait 状态的线程。(并没有 "先来后到"原则)在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
public class test10 {    static class WaitTask implements Runnable {        private Object locker;        public WaitTask(Object locker) {            this.locker = locker;        }        @Override        public void run() {            synchronized(locker) {                while(true) {                    try {                        System.out.println("wait 开始");                        locker.wait();                        System.out.println("wait 结束");                    } catch (InterruptedException e) {                        throw new RuntimeException(e);                    }                }            }        }    }    static class NotifyTask implements Runnable {        private Object locker;        public NotifyTask(Object locker) {            this.locker = locker;        }        @Override        public void run() {            synchronized(locker) {                System.out.println("notify 开始");                locker.notify();                System.out.println("notif 结束");            }        }    }    public static void main(String[] args) throws InterruptedException {        Object object = new Object();        Thread t1 = new Thread(new WaitTask(object));        Thread t2 = new Thread(new NotifyTask(object));        t1.start();        Thread.sleep(1000);        t2.start();    }}

6.3 notifyAll()方法

notify方法只是唤醒某⼀个等待线程. 使用notifyAll发法可以一次唤醒所有的等待线程.

6.4 wait 和 sleep的区别(面试题)

相同点:
就是都可以让线程放弃执行一段时间

不同点:
1. wait 需要搭配 synchronized 使用. sleep 不需要.
2. wait 是 Object 的方法 sleep 是 Thread 的静态方法

本篇博客到这里就结束啦, 感谢观看 ❤❤❤

???期待与你的下一次相遇!!!


点击全文阅读


本文链接:http://zhangshiyu.com/post/196892.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1