线程安全问题和锁

news2024/9/20 10:44:51

  所属专栏:Java学习      

在这里插入图片描述

 

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

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2118845.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【生成模型系列(初级)】嵌入(Embedding)方程——自然语言处理的数学灵魂【通俗理解】

【通俗理解】嵌入&#xff08;Embedding&#xff09;方程——自然语言处理的数学灵魂 关键词提炼 #嵌入方程 #自然语言处理 #词向量 #机器学习 #神经网络 #向量空间模型 #Siri #Google翻译 #AlexNet 第一节&#xff1a;嵌入方程的类比与核心概念【尽可能通俗】 嵌入方程可以…

Model-based RL动态规划(基于价值、基于策略,泛化迭代)

白盒环境和黑盒环境 白盒环境&#xff1a;知道环境的状态转移函数P(s’|s)或P(s’|s,a)和奖励函数R(s)或R(s,a)&#xff1a;   白盒环境下的学习相当于直接给出了有监督学习的数据分布&#xff08;就是有了目标靶子&#xff09;&#xff0c;不需要采样了&#xff0c;直接最小…

电脑桌面文件删除了怎么找回来?别急,快速恢复攻略在此

在日常使用电脑的过程中&#xff0c;我们经常会遇到这样的情况&#xff1a;一不小心&#xff0c;桌面上的某个重要文件被删除了。这时&#xff0c;大多数人可能会感到惊慌失措&#xff0c;不知所措。 其实&#xff0c;不必过于担心&#xff0c;因为有很多方法可以帮助我们找回…

@开发者极客们,网易2024低代码大赛来啦

极客们&#xff0c;网易云信拍了拍你 9月6日起&#xff0c;2024网易低代码大赛正式开启啦&#xff01; 低代码大赛是由网易主办的权威赛事&#xff0c;鼓励开发者们用低代码开发的方式快速搭建应用&#xff0c;并最终以作品决出优胜。 从2022年11月起&#xff0c;网易低代码大赛…

构建数字产业生态链,共绘数字经济新蓝图

在当今数字化浪潮席卷全球的时代&#xff0c;构建数字产业生态链成为了推动经济发展的关键引擎。数字产业生态链如同一个强大的磁场&#xff0c;吸引着各类创新要素汇聚&#xff0c;共同描绘出数字经济的宏伟新蓝图。 数字产业生态链的核心在于融合与协同。它将软件开发、数据分…

【原创】java+springboot+mysql学生信息管理系统设计与实现

个人主页&#xff1a;程序猿小小杨 个人简介&#xff1a;从事开发多年&#xff0c;Java、Php、Python、前端开发均有涉猎 博客内容&#xff1a;Java项目实战、项目演示、技术分享 文末有作者名片&#xff0c;希望和大家一起共同进步&#xff0c;你只管努力&#xff0c;剩下的交…

如何在Visual Studio中调试.NET源码

今天偶然在看别人代码时&#xff0c;发现在他的代码里使用了Any判断List<T>是否为空。 我一般的做法是先判断是否为null&#xff0c;再判断Count。 看了一下Count的源码如下&#xff1a; 1 [__DynamicallyInvokable] 2 public int Count 3 { 4 [__…

做了十几个数字化项目后,我对数字化转型的思考

现在讲数字化转型多是自顶向下视角&#xff0c;但企业仅提战略、目标还不够&#xff0c;必须把任务分配到每个团队及个人每天的工作中&#xff0c;这些工作与员工以前的工作不同&#xff0c;意味着团队和员工也要转型。 企业数字化转型口号再响亮&#xff0c;若一线员工工作内…

如何使用gewechat开发微信机器人

简介&#xff1a;本文将指导你如何搭建一个微信机器人&#xff0c;通过接入gewe实现智能回复与聊天功能。我们将从基础设置开始&#xff0c;逐步讲解如何配置机器人&#xff0c;并通过实例展示其实际应用。 随着人工智能技术的不断发展&#xff0c;智能机器人已经成为我们日常…

传输层协议TCP

本篇详细的讲解了有关传输层协议中 TCP 的常见知识&#xff0c;其中主要包括&#xff1a;TCP 协议段格式、确认应答机制、超时重传机制、连接管理机制&#xff08;三次握手和四次挥手&#xff09;、TIME_WAIT 和 CLOSE_WAIT 状态、滑动窗口、流量控制、延迟应答、捎带应答、面向…

宠物空气净化器352和希喂哪个品牌比较好?352、希喂宠物空气净化器终极PK

家里猫咪掉毛太厉害了怎么办&#xff1f;我家长毛、短毛猫都有&#xff0c;短毛还是猫界蒲公英银渐层&#xff0c;不会再有比曾经的我更受掉毛困扰的铲屎官了&#xff01;我曾经为了减少我家猫咪掉毛&#xff0c;鱼油、维生素A、维生素B5、碘营养物质都在喂&#xff0c;没啥用&…

table表格左键双击,单元格可编辑效果

1) 效果&#xff0c;修改内容后数据同步修改: 2) 思路 1、el-table提供了左键双击事件。 2、左键双击后&#xff0c;该单元格更改为input框后&#xff0c;input框需要获取焦点。 3、输入内容后&#xff0c;(回车按钮或者点击其他位置input框失去焦点)&#xff0c;数据需要更…

Django学习实战篇三(适合略有基础的新手小白学习)(从0开发项目)

前言&#xff1a; 在上一章中&#xff0c;我们对Django的Model层有了比较全面的认识&#xff0c;本章就来配置Django自带的admin。这里需要认识到&#xff0c;Django的Model层是很重要的一环&#xff0c;无论是对于框架本身还是对于基于Django框架开发的大多数系统而言。因为一…

私域电商 IP 化发展的探索与优势

摘要&#xff1a;本文聚焦于私域电商与社交电商的区别&#xff0c;重点探讨私域电商的 IP 属性。深入分析其在获取流量、转化用户以及挖掘用户价值方面的独特优势。同时引入链动 2 1 模式、AI 智能名片、S2B2C 商城小程序源码等元素&#xff0c;详细阐述这些元素在私域电商 IP…

前端:JavaScript中的this

前端&#xff1a;JavaScript中的this 1. this的指向2. 指定this的值3. 手写call方法4. 手写apply方法5. 手写bind方法 1. this的指向 在非严格模式下&#xff0c;总是指向一个对象&#xff1b;在严格模式下可以是任意值。 开启严格模式&#xff0c;如果是为整个脚本开启&#…

mapActions辅助函数的使用

什么是mapActions? mapActions 是 Vuex 提供的一个辅助函数&#xff0c;它允许你将组件中的方法映射为 Vuex 中的 Actions&#xff0c;以便于你可以直接从组件内部调用这些 actions。通过使用 mapActions 你可以在组件中以函数的方式引用 Vuex 中的 Actions&#xff0c;从而避…

个人随想-一道简单的AI面试题

大模型的兴起&#xff0c;很多公司现在都开始进入AI开发的新篇章&#xff0c;那么或多或少​也需要招聘一些AI的开发人员。 其实很多公司需要的&#xff0c;说白了就是一个AI开发工程师或者架构师&#xff0c;但是在招聘过程中&#xff0c;或多或少对要求写的太过夸张&#xf…

​在乙游热潮中,Soul App创新社交玩法,寻找年轻人的精神共鸣

经过8月份的连续事件,社会公众对于乙女游戏(下文简称乙游)有了全新的认知。 起初,是女子举重奥运冠军罗诗芳在社交平台与热门乙游《恋与深空》的意外互动,引发了广泛关注。“奥运冠军也玩乙游”成为社交平台热搜话题,众多玩家讨论奥运冠军是否会与他们有共同的虚拟角色喜好。 …

链动2+1:高效用户留存与增长的商业模式解析

大家好&#xff0c;我是吴军&#xff0c;任职于一家致力于创新的软件开发企业&#xff0c;担任产品经理的职位。今天&#xff0c;我打算深入分析一个历经时间考验且依旧充满活力的商业模式——“链动21”模式&#xff0c;并通过一个具体的案例和相关数据&#xff0c;展示它如何…

检测Meaven是否安装成功

一.配置本地仓库 1.创建一个文件夹用来存放jar包 2.解开第53行的注释,将存放jar包的路径复制 二.配置阿里云 三.配置jdk环境 1.JAVA_HOME 2.path地址里面配置BIN目录 四.配置Meaven_HOME 1.Meaven_HOME 2.path地址里面配置BIN目录 五.检查是否安装成功 1.mvn-v mvn -v 2.…