线程的状态及多线程带来的风险

本文详细介绍了Java线程的六种状态,包括NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING和TERMINATED,并讨论了多线程带来的线程安全问题,如随机性导致的bug和典型的线程不安全案例。接着,文章深入讲解了synchronized关键字的作用,包括互斥、内存可见性和可重入性。最后,探讨了volatile关键字的内存可见性以及wait和notify在处理线程同步中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


一、线程的状态

1.1 NEW

  • NEW: 安排了工作, 还未开始行动;
    Thread对象创建好了,但是还没有调用start
public class Test01 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
                while (true){
                   
                }
        });
        System.out.println(t.getState());//获取线程t的状态  1.
        t.start();
    }
}

执行结果:
在这里插入图片描述

1.2 RUNNABLE

  • RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.;
public class Test01 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
                while (true){
                    // 3.runnable
                    //这里啥也不能有
                }
        });
        t.start();
        Thread.sleep(1000);// 2.
        System.out.println(t.getState());//获取线程t的状态

    }
}

执行结果:
在这里插入图片描述

就绪状态,处于这个状态的线程,就是在就绪队列中.随时可以被调度到CPU上,
如果代码中没有进行sleep,也没有进行其他的可能导致阻塞的操作,代码大概率是处在Runnable状态的 。

1.3 BLOCKED

  • BLOCKED: 这几个都表示排队等着其他事情;
    当前线程在等待锁,导致了阻塞(阻塞状态之一) synchronized

1.4 WAITING

  • WAITING: 这几个都表示排队等着其他事情;
    当前线程在等待唤醒,导致了阻塞(阻塞状态之一) wait

1.5 TIMED_WAITING

  • TIMED_WAITING: 这几个都表示排队等着其他事情;
    代码中调用了sleep,就会进入到TIMED_WAITING,join(超时时间)
    意思就是当前的线程在一定时间之内是阻塞的状态。
public class Test01 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
                while (true){
                    try {
                    //代码中调用了`sleep`
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
        });
        t.start();
        Thread.sleep(1000);// 2.
        System.out.println(t.getState());//获取线程t的状态
    }
}

输出结果:
在这里插入图片描述

1.6 TERMINATED

  • TERMINATED: 工作完成了。
    操作系统中的线程已经执行完毕,销毁了.但是 Thread对象还在,获取到的状态.
public class Test01 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{

        });
        t.start();
        Thread.sleep(1000);// 2.
        System.out.println(t.getState());//获取线程t的状态
    }
}

输出结果:
在这里插入图片描述

这个细化的原因是:在开发过程中经常会遇到一种情况,程序“卡死”了,一些关键的线程阻塞了,在分析卡死原因的时候,第一步就可以先来看看当前程序里的各种关键线程所处的状态。

线程状态转换图
在这里插入图片描述

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

2.1 线程安全的概念

操作系统调度线程的时候,是随机的(抢占式执行),正是因为这样的随机性,就可能导致程序的执行出现一些bug。
如果因为这样的调度随机性引入了bug,就认为代码是线程不安全的。
如果是因为这样的调度随机性,没有带来bug,就认为代码是线程安全的。

2.2 线程不安全的典型案例

使用两个线程,对同一个整型变量,进行自增操作,每个线程自增5w次,查看最终的结果。

class Counter{
    public int count;
    //加锁之后,就变成线程安全的了
//   synchronized public void increase(){
        public void increase(){
       	   count++;
    }
}
public class Test02 {
    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();
        //这俩个join 谁在前,谁在后,都没关系
        //由于线程调度是随机的.咱们也不知道t1先结束,还是t2先结束
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

输出结果:
在这里插入图片描述
这5w对并发相加中,有时候可能是串行的(+2),有的时候是交错的(+1),具体串行的有多少次,交错的有多少次,都是随机的。
极端情况下:
如果所有的操作都是串行的, 此时结果就是10w(可能出现的,但是小概率事件)
如果所有的操作都是交错的,此时结果就是5w(可能出现的,也是小概率事件)

对于t1.join();t2.join();

假设t1先结束:先执行t1.join,然后等待t1结束,t1结束了;接下来调动t2.join,等待t2结束,t2结束了,t2.join执行完毕。

假设t2先结束:先执行t1. join,等到t1结束.
t2结束了,t1还没结束.main 线程仍然阻塞在t1.join中.再过一会, t1结束了, t1.join返回,
执行t2.join (此时由于t2已经结束了),t2.join就会立即返回。

count++到底干了什么?
站在CPU的角度来看待,count++实际上是三个CPU 指令!!!
在这里插入图片描述
因为操作系统调度线程的时候"抢占式执行",这就导致两个线程同时执行这三个指令的时候,顺序上充满了随机性

在这里插入图片描述
另一种情况:
在这里插入图片描述
在这里插入图片描述
在"抢占式执行”的情况下, t1t2的这三个指令之间的相对顺序是充满随机性,上述情况都可能发生.并且哪种情况出现多少次,是否出现,都无法预测!

2.3 (重点)线程不安全的原因

  1. 线程是抢占式执行,线程间的调度充满随机性.[线程不安全的万恶之源!!]
  2. 多个线程对同一个变量进行修改操作。(如果是多个线程针对不同的变量进行修改,没事!如果多个线程针对同一个变量读,也没事!)可以通过调整代码结构,使不同线程操作不同变量。
  3. 针对变量的操作不是原子的~(讲数据库事务的时候也讲到过原子性)此处说的操作原子性也是类似
    针对有些操作,比如读取变量的值,只是对应一条机器指令,此时这样的操作本身就可以视为是原子的。通过加锁操作,也就是把好几个指令给打包成一个原子的了。加锁操作,就是把这里的多个操作打包成一个原子的操作。
  4. 内存可见性,也会影响到线程安全。
    例:针对同一个变量,一个线程进行读操作(循环进行很多次),一个线程进行修改操作(合适的时候执行一次)。
    在这里插入图片描述
    t1这个线程,在循环读这个变量.按照之前的介绍,读取内存操作,相比于读取寄存器,是一个非常低效的操作(慢3-4个数量级),因此在t1中频繁的读取这里的内存的值,就会非常低效,而且如果t2线程迟迟不修改, t1线程读到的值又始终是一样的值!!因此, t1就有了一个大胆的想法!!!就会不再从内存读数据了,而是直接从寄存器里读(不执行load 了),一旦t1做出了这种大胆的假设,此时万一t2修改了count 值, t1就不能感知到了。
//线程不安全原因---内存可见性
public class Test03 {
    //加上volatile保证了内存的可见性,但其不能保证原子性
    public  static volatile int isQuite = 0;
//    public  static  int isQuite = 0;

    public static void main(String[] args) {
        Thread t = new Thread(()->{
            while (isQuite == 0){
//                try {
//                    //在循环中加入sleep,这里的优化就消失了。
//                    Thread.sleep(1000);
//                } catch (InterruptedException e) {
//                    e.printStackTrace();
//                }
            }
            System.out.println("循环结束 t线程退出!");
        });
        t.start();

        Scanner scanner  = new Scanner(System.in);
        System.out.println("请输入一个数:");
        isQuite = scanner.nextInt();
        System.out.println("main 线程执行完毕");
    }
}

不加锁的执行结果:程序一直在循环当中
在这里插入图片描述
加上volatile 之后的执行结果:
在这里插入图片描述
内存可见性,是属于编译器优化范围中的一个典型案例,编译器优化本身是一个玄学的问题.对于普通程序猿来说,啥时候优化,啥时候不优化,很难说。
像上述代码中:循环中加上sleep,这里的优化就消失,也就没有内存可见性问题了。

  • 使用synchronized关键字

(synchronized不光能保证指令的原子性,同时也能保证内存可见性同时还能禁止指令重排序)被synchronized包裹起来的代码,编译器就不敢轻易的做出上述假设,相当于手动禁用了编译器的优化。

  • 使用volatile关键字

volatile和原子性无关,但是能够保证内存可见性.禁止编译器做出上述优化.编译器每次执行判定相等,都会重新从内存读取 isQuit的值!

5.指令重排序,也会影响到线程安全问题。
指令重排序,也是编译器优化中的一种操作,咱们写的很多代码,彼此的顺序,谁在前
谁在后无所谓,编译器就会智能的调整这里代码的前后顺序从而提高程序的效率。
保证逻辑不变的前提,再去调整顺序,如果代码是单线程的程序,编译器的判定一般都是很准;但是如果代码是多线程的,编译器也可能产生误判。

三、synchronized 关键字-监视器锁monitor lock

同步的:同步这个词,在计算机中是存在多种意思,不同的上下文中,会有不同的含义。
比如,在多线程中,线程安全中,同步其实指的是"互斥"
比如在IO或者网络编程中,同步相对的词叫做“异步’,此处的同步和互斥没有任何关系。和线程也没有关系了,表示的是消息的发送方,如何获取到结果。

3.1 synchronized的使用方式

  1. 直接修饰普通的方法.
    使用synchronized的时候,本质上是在针对某个"对象"进行加锁,此时锁对象就是this
public class SynchronizedDemo {
//就是针对this来加锁,加锁操作就是在设置this的对象头的标志位.
	public synchronized void methond() {
	
	}
}

一个对象,在Java中,每个类都是继承自Object,每个new出来的实例,里面一方面包含了你自己安排的属性,一方面包含了“对象头",对象的一些元数据。

如:
在这里插入图片描述
2. 修饰一个代码块
需要显式指定针对哪个对象加锁. (Java中的任意对象都可以作为锁对象)
这种随手拿个对象都能作为锁对象的用法,这是Java中非常有特色的设定.(别的语言都不是这么搞.正常的语言都是有专门的锁对象)

锁当前对象:

public class SynchronizedDemo {
	public void method() {
		synchronized (this) {
	
		}
	}
}

锁类对象:

public class SynchronizedDemo {
	public void method() {
		synchronized (SynchronizedDemo.class) {
		
		}
	}
}
  1. 修饰一个静态方法.

相当于针对当前类的类对象加锁.
类对象,就是咱们在运行程序的时候,.class文件被加载到JVM内存中的模样 .
反射机制,都是来自于.class赋予的力量 .Counter.class(反射)

public class SynchronizedDemo {
	public synchronized static void method() {
	
	}
//
    public static void method() {
		synchronized (Counter.class){
		
		}
	}
}

3.2 synchronized的特性

互斥

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

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

刷新内存

synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁
    所以 synchronized 也能保证内存可见性.

可重入

同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入.如果不会死锁,就是可重入。
synchronized实现了可重入锁,对于可重入锁来说,连续加锁,不会导致死锁。

可重入锁的意义就是降低了程序猿的负担.(使用成本,提高了开发效率),但是也带来了代价:程序中需要有更高的开销(维护锁属于哪个线程,并且加减计数.降低了运行效率)。

在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.

  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到).

死锁的其他场景

1.一个线程,一把锁
2.两个线程,两把锁
3.N个线程,M把锁
例:哲学家就餐问题
每个哲学家,会做两件事:1.思考人生,2.吃面条
每个哲学家啥时候思考人生,啥时候吃面条,是不确定(随机的)
每个哲学家吃面条的时候,都需要拿起他身边的两根筷子(假设先拿起左手的,后拿起右手的),每个哲学家都是非常固执的,如果想吃面条的时候,尝试拿筷子发现筷子被别人占用着,就会一直等!!!
在这个模型中,如果五个哲学家,同时伸出左手,拿起左手的筷子此时,就死锁了。
约定,让哲学家拿筷子,不是先拿左手,后拿右手了,而是先拿编号小的,后拿编号大的。

死锁的四个必要条件

1.互斥使用:一个锁被一个线程占用了之后,其他线程占用不了(锁的本质,保证原子性)
2.不可抢占:一个锁被一个线程占用了之后,其他的线程不能把这个锁给抢走.
3.请求和保持:当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是被该线程持有。
4.环路等待:等待关系,成环了(A等B,B等C, C又等A)。

如何避免出现环路等待?
只要约定好,针对多把锁加锁时候,有固定的顺序即可。所有的线程都遵守同样的规则顺序,就不会出现环路等待。

Java 标准库中的线程安全类

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

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的. 使用了一些锁机制来控制.

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

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

  • String
    String方法没有synchronized,String是不可变对象,无法在多个线程中同时改同一个 String (单线程中都没法改String)。

四、 volatile 关键字

volatile 能保证内存可见性

禁止编译器优化,保证内存可见性
计算机要想执行一些计算,就需要把内存的数据读到CPU寄存器中,然后再在寄存器中计算,再写回到内存中。但CPU访问寄存器的速度,比访问内存快太多了.当CPU连续多次访问内存,发现结果都一样,CPU就想偷懒。

JMM:Java Memory Model (Java内存模型)

JMM就是把上述讲的硬件结构,在Java中用专门的术语又重新抽象封装了一遍。

在这里插入图片描述
一方面,是因为Java 作为一个跨平台的编程语言,要把硬件的细节封装起来(期望程序猿感知不到CPU,内存等硬件设备)。假设某个计算机没有CPU,或者没有内存,同样可以套在上述的模型中。

CPU 从内存取数据,取的太慢了,尤其是频繁取的时候,就可以把这样的数据直接放到寄存器里,后面直接从寄存器来读(寄存器,空间太紧张),于是CPU又另外搞了一个存储空间,这个空间,比寄存器大,比内存小.速度比寄存器慢,比内存块,称为缓存(cache),一般常见的都有三层缓存。

volatile 不保证原子性

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

3.4 wait 和 notify

是处理线程调度随机性的问题的.有时候不喜欢随机性,需要让彼此之间有一个固定的顺序。join 也是一种控制顺序的方式,但更倾向于控制线程结束。

waitnotify 都是Object对象的方法。
调用wait方法的线程,就会陷入阻塞.阻塞到有其他线程通过notify来通知。

public class Test05 {
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            synchronized (locker){
                System.out.println("wait 前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait 后");
            }
        });
        t1.start();

        Thread.sleep(3000);

        Thread t2 = new Thread(()->{
            synchronized (locker){
                System.out.println("notify 前");
               locker.notify();
                System.out.println("notify 后");
            }
        });
        t2.start();
    }
}

执行结果:
在这里插入图片描述

wait内部会做三件事:

  1. 先释放锁
  2. 等待其他线程的通知.
  3. 收到通知之后,重新获取锁,并继续往下执行。

因此要想使用wait / notify,就得搭配synchronized;

wait和notify都是针对同一个对象来操作的.
例如现在有一个对象O
有10个线程,都调用了o.wait.此时10个线程都是阻塞状态.
如果调用了o.notify,就会把10个其中的一个给唤醒.(唤醒哪个是不确定的)
针对o.notifyAll,就会把所有的10个线程都给唤醒wait 唤醒之后,会重新尝试获取到锁 (这个过程就会发生竞争)。相对来说,更常用的还是notify

wait 和 sleep 的对比

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间.

  • wait 需要搭配 synchronized 使用. sleep 不需要.
  • waitObject 的方法, sleepThread 的静态方法.

评论 21
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值