线程安全问题和锁

举报
2的n次方 发表于 2024/10/24 13:11:56 2024/10/24
【摘要】 1. 线程的状态新建(New)状态:当一个线程对象被创建,但还未调用 start () 方法启动时,处于新建状态。此时线程仅仅是一个 Java 对象,系统尚未为其分配资源。就绪(Runnable)状态:一旦调用了线程的 start () 方法,线程就进入就绪状态它等待着系统分配资源和调度,以便能够在 CPU 上运行,或者说正在CPU上运行的也可以叫做就绪状态等待状态(Waiting):线程可...

1. 线程的状态

新建(New)状态当一个线程对象被创建,但还未调用 start () 方法启动时,处于新建状态。此时线程仅仅是一个 Java 对象,系统尚未为其分配资源。

就绪(Runnable)状态一旦调用了线程的 start () 方法,线程就进入就绪状态它等待着系统分配资源和调度,以便能够在 CPU 上运行,或者说正在CPU上运行的也可以叫做就绪状态

等待状态(Waiting)线程可以通过调用wait () 方法或者 Thread.join () 方法进入等待状态。与阻塞状态不同的是,处于等待状态的线程需要被其他线程通过 notify () 或 notifyAll () 方法唤醒,或者等待特定的时间后自动唤醒。

超时等待状态(Timed Waiting):线程可以通过调用 sleep (long millis) 方法,wait (long timeout) 方法或者 join方法的带参数版本进入超时等待状态。在这种状态下,线程会等待一段时间,如果在这段时间内没有被唤醒,它会自动唤醒并进入就绪状态。

阻塞状态(Blocked):
线程在运行过程中可能会因为某些原因进入阻塞状态。常见的阻塞情况有:

  1. 等待获取锁:当一个线程试图进入一个同步代码块,但该代码块被其他线程占用时,它会进入阻塞状态,等待获取锁。
  2. 等待 IO 操作完成:当线程进行输入 / 输出操作,如读取文件或从网络接收数据,而这些操作尚未完成时,线程会进入阻塞状态。
  3. 调用 Object.wait () 方法:当一个线程在对象上调用 wait () 方法时,它会进入阻塞状态,等待另一个线程调用该对象的 notify () 或 notifyAll () 方法来唤醒它。

终止状态(Terminated)
当线程的 run () 方法执行完毕,或者在执行过程中出现异常而退出时,线程进入终止状态,此时虽然Thread对象还在,但是内核的线程已经销毁,一旦线程进入终止状态,就不能再被启动。

public class ThreadDemo9 {
    public static void main(String[] args) throws InterruptedException {
        Thread mainThread = Thread.currentThread();
        Thread thread = new Thread(()->{
            while (true){
                System.out.println("mainThread: " + mainThread.getState());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        System.out.println("thread start前: " + thread.getState());
        thread.start();
        System.out.println("thread start后:" + thread.getState());
        thread.join();
    }
}

用jconsole可以直接看到线程的状态:


2. 线程安全问题

先来看一个示例:

public class ThreadDemo10 {
    public static int cnt = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                cnt++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                cnt++;
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(cnt);
    }
}

我们的目的是通过两个线程同时对cnt进行自增的操作,正常的情况下最终的输出应该是20000的,但是每一次的运行都是一个比20000小的数字,这就是线程安全问题


先来分析一下,对于cnt++这样的操作,在CPU中其实是分为三个命令的:

  1. 把内存中的数据取出来,读取到CPU寄存器中
  2. 把CPU寄存器里的数据+1
  3. 把寄存器里的值写回内存中

之后,由于CPU在调度进程的时候是“抢占式执行,随机调度”,指令是CPU运行的最小单位,一个指令执行完毕之后才会调度,但是由于上述操作占了三个指令,就可能在中间过程中被其他线程抢走,而上面是两个进程同时对cnt进行操作的,所以就会导致数据异常,例如,线程a刚把数据读出来,线程b就抢走了,并执行提交了数据,此时线程a再执行操作之后,读取的数据还是原来的,并不是线程b修改之后的,cnt就比预期的少加了1,这只是其中一种情况

原因就是:

  1. 线程在操作系统中,随机调度,抢占执行(根本原因)
  2. 多个线程同时修改同一个变量
  3. 修改操作不是“原子”的(也就是cnt++占用三个指令,a = 1这样的赋值操作是原子的)

再来看一个例子:

使用多线程实现三个窗口卖票的业务

这时就出现了一些小问题,售卖的票中有相同的票,也有超出范围的票,出现这个问题的原因就是线程执行时是有随机性的,当一个线程休眠时,其他的线程就可以抢到CPU了,休眠之后就又可以争夺CPU,此时如果一个线程刚好执行到target++,还没来得及打印,其他线程抢回了CPU,并且执行了target++,这时就可能出现以上的情况

解决办法:把操作共享数据的代码锁起来,锁默认打开,如果有现成进去之后,锁自动关闭,里面的代码全部执行完毕,线程出来,锁自动打开,这样就可以解决上述问题

2.1. volatile关键字

线程安全的第四个原因:内存可见性引起的线程安全问题,也就是一个线程对共享变量的修改不能及时被其他线程看到,从而产生内存可见性问题

来看下面的一个例子:

public class ThreadDemo13 {
    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(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag = sc.nextInt();
        });
        t1.start();
        t2.start();
    }
}

但是最终结果并没有和我们预料的那样,当线程2输入一个不为0的数后,线程一结束,程序一直是就绪状态,并且在jconsole中看到线程仍处于就绪状态


上面出现的问题就是内存可见性问题,这是因为在 Java 中,为了提高性能,编译器/JVM和处理器可能会对指令进行重排序。


这段代码分为两步进行:

  1. 从内存中读取数据到寄存器中(读取内存,相比之下速度慢)
  2. 通过类似与cmp的命令,比较寄存器中的数据和0的值(速度快)

在JVM看来,每次循环结果都一样,并且开销非常大,就把1的操作优化掉了,每次循环就不读取内存中的值了,直接读取寄存器/cache中的数据,但是这样的话,当用户修改flag的值的时候,虽然内存中已经改变了,但是内存中flag的改变对线程一来说是不可见的,这就引起了内存可见性问题

此时只需要加一个sleep就没有刚刚的问题了,因为相比sleep来说,读取内存的速度又是非常快的,就没有上述优化了


如果说不要sleep,就可以通过volatile关键字修饰变量,相当于给编译器注明这个变量是“易变”的,此时就不会再进行上面的优化了



3. 锁

3.1. 同步代码块

同步代码块是通过关键字synchronized来实现的,括号中需要传入一个锁对象,可以是任意的,但必须是唯一的,通常会使用Thread.class作为锁对象,因为字节码文件对象是唯一的

synchronized (锁对象){
            
}
public class MyThread3 extends Thread {
    static int ticket = 0;
    @Override
    public void run() {
        while (true) {
            synchronized (Thread.class) {
                if (ticket < 100) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    ticket++;
                    System.out.println("正在卖第" + ticket + "张票");
                } else {
                    break;
                }
            }

        }
    }
}

要注意的是,synchronized在这里不能写在while循环外面,不然的话只有线程一就把循环的内容执行完了,然后剩余的线程由于target不满足循环条件,就不会再执行了

同理,第一个例子也可以加上synchronized


如果说上面两个线程中,synchroized传入的锁对象不是同一个的话,那么两个线程的锁就没有任何关系,还是和之前一样的随机调度并发执行

通过使用锁,就把两个线程锁中的内容变成串行,剩下的内容仍然是并发执行的

如果说是多个线程都加锁的话,例如线程1,2,3都要加上锁,加入当1拿到锁并释放了锁之后,之后的锁谁拿到也是不确定的

3.2. 同步方法

把synchronized加在方法上就是同步方法

格式:修饰符 synchronized 返回类型方法名(方法参数){...};

特点:同步方法是锁住方法里面所有的代码,锁对象不能自己指定

在非静态方法中,锁对象为this所指的对象


上面的这两种方式是一样的

在static静态方法中,锁对象指的是当前类的字节码文件的对象


还是上面的例子,这次 实现 Runnable接口,使用同步方法试一下

public class MyRunnable implements Runnable {
    int ticket = 0;

    @Override
    public void run() {
        while (true) {
            if (func()) break;
        }
    }

    private synchronized boolean func() {
        if (ticket == 100) {
            return true;
        } else {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ticket++;
            System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票");
        }
        return false;
    }
}

总结:可以把任意的Object/Object的子类对象作为锁对象,重要的是锁对象要是同一个,是同一个才会出现阻塞/锁竞争,不是的话就不存在阻塞/锁竞争,同时呢,也并不是写了synchronized就一定安全,怎么加锁需要根据具体场景分析,使用锁就可能发生阻塞,一旦某个进程阻塞了,什么时候恢复就不能预料了

3.3. Lock锁

上面的同步代码块和同步方法虽然也是起到了把一段代码锁起来的效果,但是并没有直接看出哪里加上了锁,哪里释放了锁,为了更清晰的表达如何加锁和释放锁,JDK5以后提供了一个新的锁对象Lock,Lock实现提供比使用synchronized方法和语句可以获得更广泛的锁定操作

Lock中也提供了获得锁和释放锁的方法

void lock() : 获得锁

void unlock() : 释放锁

Lock是一个接口,所以需要通过它的实现类ReentrantLock来实例化对象,然后再调用上面两个方法

以之前创建的MyThread3为例,由于需要创建三个MyThread3的对象,所以在MyThread3中创建的锁对象也会被创建三次,那么就会出现之前超出范围的问题,所以创建的锁对象要用static修饰一下


但这时会出现一个问题,程序最终并没有停止


这是因为假如线程一抢到了CPU,并执行完毕之后跳出了循环,线程二和线程三还在锁的外面,所以需要改变释放锁的位置,可以利用 finally 来解决这个问题

public class MyThread3 extends Thread {
    static int ticket = 0;
    static Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            //synchronized (Thread.class) {
            lock.lock();
            try {
                if (ticket == 100) {
                    break;
                } else {
                    Thread.sleep(10);
                    ticket++;
                    System.out.println(getName() + "正在卖第" + ticket + "张票");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
            // }
        }
    }
}

3.4. 死锁

3.4.1. 场景一

先来看一个例子:

class Counter{
    public int cnt = 0;

    public void add() {
        synchronized (this) {
            cnt++;
        }
    }
}
public class ThreadDemo11 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (ThreadDemo11.class) {
                    counter.add();
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (ThreadDemo11.class) {
                    counter.add();
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.cnt);
    }
}

在上面的例子中,里面的synchronized要想拿到锁,就需要外面的synchronized,但是外面的synchronized要想释放锁,就要执行到add方法,但是add方法被里面锁了,这个线程针对这把锁连续加锁了两次,形成了死锁,所以说,这段代码是存在问题的,不过运行之后发现也能成功运行


这是因为synchronized对这种情况做出了处理,叫做“可重入锁”,就是在锁中额外记录一下,当前是哪个线程对这个锁加锁了,对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何加锁操作,也不会进行任何阻塞操作,可以继续执行接下来的代码,此外,还会通过引入一个计数器,来维护当前加锁了几次,以此来判断什么时候释放锁

3.4.2. 场景二

线程一先对A加锁,线程二对B加锁,线程一不释放A的情况下,再针对B加锁,同时线程二在不释放B的情况下,再针对A加锁,针对这样的死锁情况,可重复加锁的机制就没用了

例子:我到校门口被保安拦住了

保安:请出示校园卡

我:校园卡忘学校了,让我进学校给你拿

保安:先出示校园卡再放你进去拿

我:?

来用代码来模拟一下

public class ThreadDemo12 {
    public static Object lock1 = new Object();
    public static Object lock2 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized (lock1){
                //sleep是为了确保t1,t2先分别拿到lock1和lock2再去拿对方的锁
                System.out.println("t1加锁lock1完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (lock2){
                    System.out.println("t1加锁lock2完成");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (lock2){
                System.out.println("t2加锁lock2完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (lock1){
                    System.out.println("t2加锁lock1完成");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

运行之后发现程序一直没有结束,并且两个线程都被阻塞了


3.4.3. 场景三

哲学家就餐问题:

有五位哲学家围坐在一张圆形餐桌旁,每一位哲学家面前都有一盘意大利面和一把叉子。由于哲学家们需要同时进行思考和进食,而进食的时候需要同时拿起左右两边的叉子。但是如果所有哲学家都同时拿起左边的叉子,那么他们就会陷入死锁状态,谁也无法拿到右边的叉子进行进食。

3.4.4. 死锁产生的原因和解决方案

死锁产生的四个必要条件:

  1. 锁是互斥的(一个线程拿到了锁,另一个线程就拿不到这把锁)
  2. 锁是不可抢占的(线程一拿到锁之后,只要不释放,其他线程就抢不过来)
  3. 请求和保持(线程一拿到锁A之后,不释放锁A的前提下去拿B锁,先释放A再拿B时不会出现问题的)
  4. 循环等待/环路等待/循环依赖(进校门的例子)

解决方案:

如果说是由于请求和保持的原因,可以先把原来的锁释放掉再去拿其它的锁(避免锁的嵌套)

如果说需要代码按照请求和保持的方式获取到n把锁,怎么去避免循环等待:

可以给锁加上编号,约定所有的线程在加锁的时候,都必须按照一定的顺序来加锁,例如哲学家就餐的问题,给5把锁边上编号,每次只能拿编号小的那个,那么最后一个人就面临着5号和1号锁,但是不能拿5号锁,就得等第一个人把1号锁放下来才能用,这就避免了循环等待

刚刚有问题的代码也可以通过约定加锁顺序来解决,先加锁lock1,再加锁lock2


【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。