Java中的多线程安全问题

news2025/7/3 19:58:38

目录

一、什么是线程安全?

二、线程不安全的原因

2.1 从底层剖析count++的操作

2.2 线程不安全的原因总结

2.3 JVM内存模型(JMM)

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

 3.1 如何加锁(Synchronized用法和特性)

3.1.1. 独占性

3.1.2 可重入性

四、Java 标准库中的线程安全类

五、volatile关键字

5.1 volatile可以保证内存可见性

5.2 volatile不可以保证原子性


一、什么是线程安全?

简单的理解:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

观察一下代码:使用两个线程,每个线程都对这个 Counter进行5w次自增,预计是结果为10w。

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: 86136
 * Date: 2023-01-12
 * Time: 20:50
 */
class Counter {
    public int count = 0;

    public void increase() {
        count++;
    }
}
public class Demo13 {
    static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        //使用两个线程,每个线程都对这个 Counter进行5w次自增
        //预计是结果为10w

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果:

二、线程不安全的原因

上面我们使用了多线程运行了一个程序,想让这个变量从0自增到10w次,但是最终实际结果比我们预期的结果要小,这是线程调度顺序的随机性导致的,造成了线程间自增的指令集交叉,导致本来需要自增两次但值只自增了一次的情况。所以得到的结果偏小。

2.1 从底层剖析count++的操作

count++ 操作,在底层其实是被分为三条指令在CPU上进行执行的

  1. 把内存的数据读取到CPU的寄存器上(load)
  2. 把CPU的寄存器中的值,进行+1(add)
  3. 把寄存器中的值,写回到内存中(save)

这里简单的描述几种情况,初始条件:初始值为1,对其进行两次自增。

🚩情况1 :线程间的指令集,没有交叉,运行结果和预期结果相同,

 🚩情况2:线程间指令集存在交叉,运行结果低于预期结果

 🚩情况3:线程间的指令集完全交叉,实际结果低于预期。

根据上面我们所举出的情况,可以得出,满足线程安全需要具备原子性(自增操作的三条指令可以拆分,不具备这个特点)。

2.2 线程不安全的原因总结

  1. 抢占式执行,可以说是线程不安全的万恶之源:多个线程的调度执行过程是“完全随机的‘(这里并非是数学上的完全随机,但是确实是没有规律可言)。
  2. 多个线程修改同一个变量。
  3. 修改操作不是原子的,如count++一样,其本质在CPU上其实会被分为load,add,save这三个单位。解决线程安全最常见的手段就是从这里入手,通过一些方法将这些指令打包为一个整体。
  4. 内存可见性问题,这是JVM的代码优化背景下,在运用到多线程中引入的bug。
  5. 指令重排序:可以理解为规划最优路线,这是一种编译器优化,举个栗子:洗碗,烧水,和拖地,我们可以在洗碗的时候同时进行烧水的工作。

对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价

现在我们对以上的 内存可见性 进行一定的补充:

可见性的定义

:一个线程对共享变量值的修改,能够及时地被其他线程看见。

2.3 JVM内存模型(JMM)

Java虚拟机规范中定义了Java内存模型,目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

 

  • 线程之间的共享变量存在 主内存 中(Main Memory)
  • 每一个线程都有自己的“工作内存”(Work Memory)
  • 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存中,再从工作内存读取数据。
  • 当线程要修改一个共享变量的时候,也会先把工作内存中的副本,再同步到主内存中。

但是由于每个线程都有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的副本,此时如果修改线程1的工作内存的值,线程2的工作内存不一定会及时变化。

犹如下面情况:

🚩初始情况 :当两个线程的工作内存相同时候。

 🚩后续:一旦线程1修改了a的值,那么主内存不一定能够及时同步,对应的线程2的工作内存的a值也不一定能及时同步。

这时的代码可能就会出现问题。  

思考:为什么需要那么多内存?为什么要这么麻烦的拷贝来拷贝去?

1):实际并没有那么多的内存,这只是Java规范中的一个术语,是属于抽象的叫法,因为我们需要更好的通用性/跨平台性,Java语言的初衷其实就是让程序员们感受不到硬件上的差异。(比如工作内存就和硬件无关,因为CPU内部的结构是不一样的,有的CPU是只带寄存器,有的带寄存器和缓存,有的CPU缓存还有多级缓存(L1,L2,L3)).这时不同CPU存储方式就不一样了,此时使用工作内存就代指上面一套(寄存器+缓存)。

所谓的 “主内存”,才是真正硬件角度的内存,而所谓的 “工作内存”则是指:CPU的寄存器和高速缓存。

2):因为CPU访问自身寄存器的速度以及高速缓存的速度,远超于访问内存的速度(快了3-4个数量级)。

比如某个代码要连续10次读取某个变量的值,如果10次都从内存读,速度是很慢的,但是如果只是第一次从内存读,读到的结果缓存到CPU的某个寄存器中,那么后9次读数据就不必访问内存了,效率大大提高。

那么问题来了,既然访问寄存器的速度这么快,那么全部都用寄存器不就好了,为什么还需要内存呢?

:因为CPU中的寄存器价格昂贵,内存次之,硬盘最便宜。所以导致了需要内存配合寄存器使用。

值得一提的是,快和慢是相对的,CPU访问寄存器的速度远远高于访问内存的速度,但是内存的访问速度又远远快于硬盘。

这里用一段代码再次演示以便加深印象:

我们已经知道,访问寄存器的速度是远远高于内存的,在下面这段代码中,while循环内会频繁的进行读内存(LOAD)和比较的操作(CMP,比较寄存器中的值是否为0).

由于load消耗的时间比CMP的时间慢了3~4个数量级,于是编译器就会对这一现象进行优化:

编译器认为因为要频繁的执行load,而且load得到的结果是一样的(编译器这么认为的,实际不一定,比如下面我们举的例子)。于是就只执行一次load,后续在进行CMP的时候就不会再读内存了,而是读JMM中的工作内存(寄存器的值或者缓存)。

但是如果在这期间,有人修改了这个值,代码就会因为编译器的优化而出现bug:

 结论:编译器优化会在多线程的情况下可能会出现误判。

既然编译如果在自己判断不准确的话,把不该优化的地方优化了,那么就可以让程序员们显示的提醒编译器,该地方无需优化。这也正是volatile的作用。

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

那么以上的问题如何解决呢?那就需要引入Java中的”锁”

再回顾刚刚的代码,我们只需要将increase方法用synchronized修饰即可。

class Counter {
    public int count = 0;

    public synchronized void increase() {
        count++;
    }
}

整段代码:


class Counter {
    public int count = 0;

    public synchronized void increase() {
        count++;
    }
}
public class Demo13 {
    static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        //使用两个线程,每个线程都对这个 Counter进行5w次自增
        //预计是结果为10w

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果:

 3.1 如何加锁(Synchronized用法和特性)

3.1.1. 独占性

利用synchronized的独占性,也称互斥性,通俗一点来说就是如果当前锁没有被上锁,那么这个加锁操作就会成功,如果当前锁已经被人加上,加锁操作就会阻塞等待。

  • 进入 synchronized 修饰的代码块,相当于加锁
  • 退出 synchronized 修饰的代码块,相当于解锁

 加锁之后,原先可以交叉执行的指令就无法交叉,变成了一个整体。

如果仍无法理解线程1在参与其他工作时候,线程2无法进入CPU执行,可以看一下这个栗子:

类似于图书馆占座,小A同学占座之后不一定每时每刻都在图书馆位子上,可能中途上厕所,饭点去吃饭之类的,而这期间小B同学想使用小A的这个位置是不被允许的。

 如果把for也写到加锁代码里,这时候就跟完全串行一样了。

总结:加锁要考虑好锁哪段代码,锁的代码范围不一样,对代码执行效果会有很大影响,锁的代码越多,就叫做”锁的粒度越大/越粗“,锁的代码越少,就叫做”锁的粒度越小/越细“。

有的同学可能会问,如果一个线程加锁,一个线程不加锁,这时线程安全能否保证?

首先我们要明白一点,线程安全,不是因为加了锁就一定安全,而是通过加锁,让并发修改同一个变量变成串行修改同一个变量才安全。结论就是不正确的加锁方式,不一定能解决线程安全问题。

举例:这时只给一个线程加锁,这个是没啥用的,一个线程加锁,不涉及到”锁竞争“,也就不会阻塞等待,相应,也就不会并发执行变成串行执行。


class Counter {
    public int count = 0;

    public synchronized void increase() {
        count++;
    }
    public  void increase2() {
        count++;
    }

}
public class Demo13 {
    static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        //使用两个线程,每个线程都对这个 Counter进行5w次自增
        //预计是结果为10w

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase2();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果仍比期望的10w小:

在日常开发的过程中,可能会碰到需要加锁的代码不完全都在一个方法中,这时该怎么处理?

其实我们的synchronized除了能修饰方法外,还可以修饰代码块。因此,我们就可以把要进行加锁的逻辑放到synchronized修饰的代码块中,也能起到加锁的效果。

写法1:

需要注意的是:synchronized里面锁的对象个数只能为一,虽然在Java中任何对象都可以被当作锁对象,但是我们并不关心对象是谁,而是关系是否锁的是同一个对象,因为只有多个线程锁同一个对象的时候,这时才能进行锁竞争。

就如下图一样,因为是针对两个不同对象进行加锁,所以不涉及到锁竞争。

写法2:针对locker对象进行加锁,locker是Counter的一个普通成员(非静态成员),每一个Counter实例中,都有自己的locker实例。

分析:这个代码中都针对counter对象进行加锁,counter对象中的locker是同一个对象,因此仍然可以导致锁竞争的产生。

延申1如果这里调用count1,count2的increase,因为locker不是同一个locker,结论

:同样无法产生锁竞争。

 延申2:将locker变为静态的。结论;会产生锁竞争。

 延申3:一个锁locker,一个锁当前对象(this),结论:不会产生锁竞争

延申4:锁对象为一个类对象 Counter.class(在JVM中只有一个,存储的是Counter中的详细信息,比如属性数量,名称,类型等),结论:会产生锁竞争。

延申5:synchronized修饰静态方法,结论:会产生锁竞争。 

 synchronized用法总结:

1.修饰普通方法,锁对象相当于this。

2.修饰代码块,锁对象需要在 ()内指定。

3.修饰静态方法,锁对象相当于 类对象(注意这里并不是锁整个类)。

3.1.2 可重入性

synchronized 是可以对同一条线程是可重入的,不会出现自己把自己锁死的情况。

何为死锁?

简单来说;就是一个线程针对一把锁加锁两次。

第一次加锁可以成功,第二次加锁就会失败

分析:因为第二次加锁的时候,锁已经被占用,要想加锁成功,必须等待第一把锁解锁,但是第一把锁要想执行完成,必须执行完第二把加锁的代码块。这造成死循环。

针对上述情况,不会产生死锁的情况,就叫做”可重入锁“,  会产生死锁的情况,就叫做”不可重入锁“。

当然 我们这里所讲到的 synchronized是可重入锁。

可重入锁的底层实现

其实道理是很简单的。原则1 就是 让这个锁记录好是哪个线程持有这把锁即可。

比如:线程t尝试对针对this进行加锁,this这个锁就记录t持有了它自己,当第二次t进行加锁的时候,锁一看还是t线程,于是就直接放行通过。这是不需要阻塞等待的。

原则2:就是内部维护一个计数器,用来衡量什么时候是真加锁,什么时候需要解锁,什么时候需要直接放行。(这些synchronized会帮我们维护好,不需要我们关心)。

ps:加锁代码中出现了一次,是不会出现死锁的情况的,无论如何代码都能执行到。这也是synchronized被设计为关键字的一个原因。

四、Java 标准库中的线程安全类

Java标准库中很多都是线程不安全的,这些类可能会设计到多线程修改共享数据,但是没有任何自带的加锁措施:

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

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

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

StringBuffer 的核心方法都带有 synchronized .

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

  • String

五、volatile关键字

5.1 volatile可以保证内存可见性

我们上面提到了volatile所修饰的变量能够保证”内存可见性“。

再次引用上面我们在JMM中提到的例子:

import java.util.Scanner;

class Counter {
    public static volatile int count = 0;
    static Object locker = new Object();
    public  void increase() {
        synchronized(Counter.class) {
            count++;
        }
    }
    public void increase2() {
        synchronized (this) {
            count++;
        }
    }
    public  synchronized void increase3() {
        synchronized (this) {
            //.....
        }
    }
}

public class Demo13 {
    static Counter counter = new Counter();
    static Counter counter2 = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while(Counter.count == 0 ) {

            }
            System.out.println("t1线程执行结束");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("请输入一个int:");
            Scanner scanner = new Scanner(System.in);
            Counter.count = scanner.nextInt();
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
//        System.out.println(counter.count);
//        System.out.println(counter2.count);
    }
}

运行结果:

分析:这里给count变量加上了volatile,强制读取了内存(禁止了编译器的优化,避免了直接读取CPU寄存器中缓存的数据,而是每次都重新读取内存),速度是变慢了,但是准确度提高了。

5.2 volatile不可以保证原子性

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

这里我们把上面count++的例子(利用两个线程,将count从0自增到10w次):

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);
}

结论: 可以看出由于volatile无法保证原子性,所以再涉及到count++这类操作(在底层会把自增操作拆分为三个指令)时,无法保证线程安全。

 

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

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

相关文章

【sklearn】模型融合_堆叠法

Stacking参数含义1. 工具库 & 数据2. 定义交叉验证函数2.1 对融合模型2.2 对单个评估器3. 定义个体学习器和元学习器3.1 个体学习器3.2 元学习器4. 评估调整模型5. 元学习器的特征矩阵5.1 特征矩阵两个问题 & Stacking5.2 StackingClassfier\Regressor参数cv - 解决样本…

C语言 动态通讯录实现(附源码)

欢迎来到 Claffic 的博客 &#x1f49e;&#x1f49e;&#x1f49e; 前言&#xff1a; 上期博客写了静态通讯录并且答应给大家写一个动态版&#xff0c;这不&#xff0c;它来了&#xff1a; 1.动态版与静态版的区别 静态版的内存空间开辟大小是固定的&#xff0c;放了预期的最…

Leetcode 剑指 Offer II 010. 和为 k 的子数组

题目难度: 中等 原题链接 今天继续更新 Leetcode 的剑指 Offer&#xff08;专项突击版&#xff09;系列, 大家在公众号 算法精选 里回复 剑指offer2 就能看到该系列当前连载的所有文章了, 记得关注哦~ 题目描述 给定一个整数数组和一个整数 k &#xff0c;请找到该数组中和为…

MTBF是什么意思?交换机做MTBF有什么要求?MTTF、MTBF和MTTR的区别是什么?

MTBF&#xff0c;即平均故障间隔时间&#xff0c;英文全称是“Mean Time Between Failure”。是衡量一个交换机的可靠性指标。单位为“小时”。它反映了交换机的时间质量&#xff0c;是体现交换机在规定时间内保持功能的一种能力。具体来说&#xff0c;是指相邻两次故障之间的平…

【考研】2020-Part A 作文(英一)

可搭配以下链接一起学习&#xff1a; 【考研】2018-Part B 作文&#xff08;英一&#xff09;_住在阳光的心里的博客-CSDN博客 目录 一、2020 Part A &#xff08;一&#xff09;题目及解析 &#xff08;二&#xff09;优秀范文 &#xff08;三&#xff09;参考译文 &a…

Ansible playbook 讲解与实战操作

文章目录一、概述二、playbook 核心元素三、playbook 语法&#xff08;yaml&#xff09;1&#xff09;YAML 介绍1、YAML 格式如下2、playbooks yaml配置文件解释3、示例2&#xff09;variables 变量1、facts:可以直接调用2、用户自定义变量3、通过roles传递变量4、 Host Invent…

LINUX---文件

目录第一部分&#xff1a;文件编程一.打开/创建文件二.文件的写入操作三.文件的读取四.文件的光标应用&#xff1a;计算文件的大小第二部分&#xff1a;文件操作原理&#xff1a;一.文件描述符静态文件和动态文件第三部分&#xff1a;文件编程小应用1.实现CP命令2.修改文件3.写…

安卓玩机搞机技巧综合资源-----修改rom 制作rom 解包rom的一些问题解析【二十一】

接上篇 安卓玩机搞机技巧综合资源------如何提取手机分区 小米机型代码分享等等 【一】 安卓玩机搞机技巧综合资源------开机英文提示解决dm-verity corruption your device is corrupt. 设备内部报错 AB分区等等【二】 安卓玩机搞机技巧综合资源------EROFS分区格式 小米红…

【Vue笔记】Vue组件的创建、使用以及父子组件数据通信常见的几种方式

这篇文章&#xff0c;主要介绍Vue组件的创建、使用以及父子组件数据通信常见的几种方式。 目录 一、Vue组件的使用 1.1、局部组件 1.2、全局组件 1.3、动态组件&#xff08;组件动态切换&#xff09; 1.4、缓存组件 &#xff08;1&#xff09;如何缓存组件 &#xff08;…

微服务技术--Nacos与Eureka

eureka注册中心 远程调用的问题 消费者该如何获取服务提供者具体信息&#xff1f; 服务提供者启动时向eureka注册自己的信息eureka保存这些信息消费者根据服务名称向eureka拉取提供者信息 如果有多个服务提供者&#xff0c;消费者该如何选择&#xff1f; 服务消费者利用负载均…

区块链技术1---密码学基础

摘要&#xff1a;BTC属于加密货币&#xff0c;其中必然涉及到密码学的知识&#xff0c;而比特币比较开放&#xff0c;交易记录&#xff0c;交易金额甚至是底层源代码都是对外开放&#xff0c;那么加密使用在何处&#xff1f;这里就来谈一谈1&#xff1a;哈希哈希函数是密码学的…

client-go实战之六:时隔两年,刷新版本继续实战

欢迎访问我的GitHub 这里分类和汇总了欣宸的全部原创(含配套源码)&#xff1a;https://github.com/zq2599/blog_demos 系列文章链接 client-go实战之一&#xff1a;准备工作client-go实战之二:RESTClientclient-go实战之三&#xff1a;Clientsetclient-go实战之四&#xff1a;…

JavaWeb开发(三)3.3——Spring Bean详解

一、Bean的概念 由 Spring IoC 容器负责创建、管理所有的Java对象&#xff0c;这些管理的对象称为 Bean&#xff0c;Bean 根据 Spring 配置文件中的信息创建。 二、基于XML方式管理bean对象 eg&#xff1a; <?xml version"1.0" encoding"UTF-8"?&…

【B-树、B+树、B* 树】多叉平衡搜索树,解决“IO次数”与“树高”问题~

目录 一、为什么会出现B-树&#xff1f; 面试题&#xff1a; 二、什么是B-树&#xff1f; 2.1、B,B-树,B*树 导航 三、B-树的模拟实现 3.1、插入结点分析 3.1.1、根节点的分裂 3.1.2、继续插入数据&#xff0c;分裂子节点 3.2.3、再次插入数据&#xff0c;导致根节点继…

tomcat和apache有什么区别?如何将内网发布到互联网访问?

tomcat、 apache是比较常用的搭建服务器的中间件&#xff0c;它们之间还是有一些区别差异的&#xff0c;我们通常会根据本地应用场景来选择合适的中间件来搭建服务器。在内网本地部署搭建服务器后&#xff0c;还可以通过快解析端口映射方法&#xff0c;将内网应用地址发布到互联…

Android原生检测Selinux的三种方法

本文介绍 3 种检测 Android 设备 SELinux 状态的方法, Java 层检测Selinux已经没有太多意义,因为不是很靠谱,随便一个hook代码就能绕过,所以我要告诉你如何在 C 层完成检测。这几种方法在效率和抵抗mock SELinux State 的技术方面都不相同,因此在使用之前你需要知道每种方…

Windows server——部署DNS服务

作者简介&#xff1a;一名云计算网络运维人员、每天分享网络与运维的技术与干货。 座右铭&#xff1a;低头赶路&#xff0c;敬事如仪 个人主页&#xff1a;网络豆的主页​​​​​​ 目录 前言 本章重点 一.DNS概述 1.DNS的诞生 二.DNS的功能 使用域名访问具有以下优点…

【大厂高频真题100题】《二叉树的序列化与反序列化》 真题练习第23题 持续更新~

二叉树的序列化与反序列化 序列化是将一个数据结构或者对象转换为连续的比特位的操作,进而可以将转换后的数据存储在一个文件或者内存中,同时也可以通过网络传输到另一个计算机环境,采取相反方式重构得到原数据。 请设计一个算法来实现二叉树的序列化与反序列化。这里不限…

c语言 图形化贪吃蛇 多种功能 无需安装第三方库 课设 (附代码)

前言 类贪吃蛇是利用c语言模仿并实现经典游戏贪吃蛇&#xff0c;使其在窗口有贪吃蛇活动的规定范围&#xff0c;并完成一系列包括但不限于模仿蛇的移动&#xff0c;方向控制&#xff0c;吃到食物加分&#xff0c;撞上墙壁及蛇头碰到蛇身死亡等游戏功能。 附加功能&#xff1a…

软件测试复习03:动态测试——白盒测试

作者&#xff1a;非妃是公主 专栏&#xff1a;《软件测试》 个性签&#xff1a;顺境不惰&#xff0c;逆境不馁&#xff0c;以心制境&#xff0c;万事可成。——曾国藩 文章目录逻辑覆盖法&#xff1a;最常用程序插桩技术基本路径法点覆盖边覆盖边对覆盖主路径覆盖符号测试错误…