《深入理解JAVA虚拟机(第2版)》- 第13章 - 学习笔记【终章】

第13章 线程安全与锁优化

13.1 概述

  • 面向过程的编程思想

    将数据和过程独立分开,数据是问题空间中的客体,程序代码是用来处理数据的,这种站在计算机角度来抽象和解决问题的思维方式,称为面向对象的编程思想。

  • 面向对象的编程思想

    将数据和行为看作是对象的一部分,这种站在现实世界的角度去抽象和解决问题的思维方式,称为面向对象的编程思想。

13.2 线程安全

首先我们先来看下在《Java Concurrency In Practice》中,作者Brian Goetz是如何来定义线程安全的:

当多个线程访问一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都能获得正确的结果,那这个对象是线程安全的。

通过以上的定义从中梳理出线程安全的代码所具备的一个必须特征是:代码本身封装了所有必要的正确性保障手段(例如:互斥同步),调用者无需考虑多线程的问题,更无须自己采取任何措施来保证多线程的正确调用

13.2.1 Java语言中的线程安全

了解了什么是现成安全之后,让我们来基于Java语言说下,线程安全是如何实现的?哪些操作是线程安全的。

按着线程安全的“安全程度”由强至弱来排序,Java语言中操作共享的数据可以分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

  1. 不可变

    • 不可变的对象一定是线程安全的。

    • 如果共享数据是基本数据类型,那么它被final关键字修饰的话就可以保证它是不可变的。

    • 如果共享数据是对象,那就需要保证对象的行为不会对它自身的状态产生影响。拿java.lang.String为例,它是一个典型的不可变对象,我们调用它的substring()、replace()和contract()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

  2. 绝对线程安全

    绝对线程安全要完全满足Brian Goetz给出的对线程安全的定义,即无论任何运行时环境,调用者都不需要进行额外的同步措施

    Java API中标注自己为线程安全的类,实际上都不是绝对线程安全的,我们拿java.lang.Vector为例,众所周知它的add()、size()、remove()和get()方法都是被synchronized修饰的,即使这样也不能保证任何时候调用它的都不需要同步手段了。以下面的代码段为例:

    private static Vector<Integer> vector = new Vector<Integer>();public static void main(String[] args) {while (true) {for (int i = 0; i < 10; i++) {vector.add(i);}​​        Thread removeThread = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < vector.size(); i++) {vector.remove(i);}}});​​        Thread printThread = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < vector.size(); i++) {System.out.println((vector.get(i)));}}});​​        removeThread.start();printThread.start()​​        //不要同时产生过多的线程,否则会导致操作系统假死while (Thread.activeCount() > 20);}
    }​​
    

    运行结果可能如下:

    Exception in thread "Thread-59775" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 18at java.base/java.util.Vector.get(Vector.java:750)at com.datapix.dao.platform.mapper.VectorTest$2.run(VectorTest.java:32)at java.base/java.lang.Thread.run(Thread.java:842)​​
    

    通过运行结果,我们可以看到,如果不在调用端加上额外的同步措施,这段代码仍然是不安全的。想象下在removeThread线程中删除一条元素,导致序号i不在可用的话,printThread线程通过序号i再去访问就会抛出ArrayIndexOutOfBoundsException异常。

    要想保证代码段正确执行下去,我们需要在调用端进行一些同步处理,如下: 在这里插入图片描述

  3. 相对线程安全

    • 我们通常所讲的线程安全说的就是相对线程安全。

    • 相对线程安全需要保证对象的单独操作是现成安全的,我们在调用的时候不需要进行额外的保障措施。

      而对于一些特定的顺序的连续性调用,就需要调用端采用一些额外的同步手段来保证调用的正确性,例如:上边绝对线程安全中提到的那个代码段。

  4. 线程兼容

    • 我们通常说的线程不安全指的就是这类情况。
    • 线程兼容是指对象本身是现成不安全的,但是可以通过在调用端采取一些同步手段来保证对象在并发的环境下可以安全的使用。
    • 在Java中的ArrayList、HashMap都属于线程兼容。
  5. 线程对立

    线程对立是指无论调用端是否采用额外的同步措施,都无法在多线程的环境中并发使用的代码。

13.2 线程安全的实现方法

13.2.1 同步

同步是指多个线程并发访问共享数据的时候,保证共享数据在同一时刻只被一个线程使用。

13.2.2 互斥同步
  1. 互斥作为实现同步的一个手段。

  2. 互斥同步又称为“阻塞同步”,使用的是悲观并发策略。

  3. 互斥的实现方式分为:临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)。

  4. Java中互斥同步手段有:关键字synchronized和J.U.C(java.util.concurrent)的重入锁(ReentrantLock)。

    • 关键字synchronized

      synchronized编译后,会在同步块的前后增加monitorenter和monitorexit两个字节码指令,这两个字节码需要reference类型的参数作为锁定和解锁的对象。

      monitorenter和monitorexit字节码指令执行过程描述如下:

      当执行monitorenter指令的时候,当前线程首先会去尝试获取对象锁。如果对象没有被锁定,或者当前线程已持有该对象锁,则锁的计数器+1。相应的,当执行monitorexit指令的时候,锁的计数器-1,直到计数器为0,锁被释放。如果对象锁获取失败,则当前线程会阻塞等待,直到其他线程将锁释放掉为止。

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

      通过下面的代码段进一步说明不会出现自己把自己锁死的情况:

       public class ReentrantTest {     public synchronized void reentrant() { // 标记①synchronized(this) { // 标记②// do something ....}}     } 
      

      让我们来试想下reentrant()方法在一个线程中(假设这个线程的名字是【线程A】)的调用:【线程A】调用reentrant()方法(即「标记①」)的时候,【线程A】获取了this对象锁(即对象锁中会标记已被【线程A】占用),在执行到「标记②」的时候,需要再次获取this对象锁,但是由于此时this对象锁已被【线程A】所占用(且「标记②」又是【线程A】中的一个步骤)。

      这里岂不是出现了自己把自己锁死了的情况了嘛?!

      但是基于synchronized是可重入的特性,即获取锁的线程(即占用锁的线程)与此时正要获取锁的线程是同一个,那么就不需要阻塞等待了。事实上在获取this对象锁的线程与「标记②」所在的线程也是同一个线程(都是【线程A】),所以这里也就不需要阻塞等待,也就不会出现死锁了!

    • J.U.C(java.util.concurrent)的重入锁(ReentrantLock)

      在基本语法上,ReentrantLock与synchronized很相似,它俩都是线程可重入的。只是在代码写法上有所不同,ReentrantLock需要显性的编写lock()和unlock()方法,而synchronized则不需要

      相比synchronized,ReentrantLock还提供了一些高级功能,主要有:等待可中断、可实现公平锁(ReentrantLock默认是非公平锁)、锁可以绑定多个条件

13.2.3 非阻塞同步
  1. 非租塞同步是使用了乐观并发策略

  2. 简单说来,非租塞同步就是先进行操作,如果操作期间没有其他线程使用共享数据,则操作成功;如果操作期间有其他线程也使用了共享数据,出现了共享数据争用的情况,那就需要采取其他补救措施了(例如:重试直到成功为止),通常这个过程不需要将线程挂起。

  3. 比较并交换(Compare-and-Swap,下文称CAS),CAS指令有三个操作数:变量的内存地址、旧值和新值。当CAS指令执行时,比较旧值与变量的内存地址中的值,如果相同,则将变量的内存地址的值更新为新值。如果不相同就不更新。无论更新与否,都将返回旧值。

    CAS指令虽然有两个动作(比较和更新),但这个指令是一个原子操作(靠硬件来实现的)

    CAS语义上并不完美,存在一个逻辑漏洞(即“ABA”问题):在CAS进行更新前的比较操作时,我们发现此时变量的内存地址上的值与旧值相同,就此我们能断定变量的内存地址上的值没有变动过吗?如果这期间有其他线程将变量的内存地址上的值先变为C,又变回了A。CAS是无法感知这个变化过程的,它会认为变量的内存地址上的值没有发生过变化。

    针对这个逻辑漏洞(即“ABA”问题),我们该如何解决呢?下面介绍三个方法:

    • 使用J.U.C提供的原子引用类(AtomicStampedReference),它通过变量值的版本来确保CAS的正确性。
    • 直接采用互斥同步
    • 直接无视(根据具体的情况),大部分情况ABA问题不会影响程序并发的正确性。
13.2.4 无同步方案
  1. 要保证线程安全,并不一定要同步,两者并无因果关系。

  2. 同步只是为了确保共享数据在被争用时的正确性

  3. 如果一段代码根本不涉及共享数据,也就无需靠同步措施来保证正确性,这样的代码天生就是线程安全的。下面简单介绍下两种天生线程安全的代码:

    • 可重入代码(Reentrant Code)

      这种代码又叫做纯代码(Pure Code),可以在代码执行的任何时候中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。

    • 线程本地存储(Thread Local Storage)

      如果一段代码中的数据必须与其他代码共享,那就要看看使用共享数据的代码是否能在一条线程中执行,如果可以,就无需考虑同步问题了。

      举个下边的例子再来对照着去理解下,如下图: 在这里插入图片描述
      从图中我们看到,【代码段1】中的「操作人」是【代码段2】中也要用到的,所以「操作人」是两段代码(代码段1和代码段2)的共享数据,【代码段1】和【代码段2】又是在一条线程当中执行,所以共享数据(即「操作人」)无需同步。

13.3 锁优化

锁优化技术的目的是为了在线程之间更高效的共享数据,以及解决冲突问题,从而提高程序的执行效率。

13.3.1 自旋锁与自适应自旋
  1. 自旋锁

    通过上文【13.2.2】中关于互斥同步的介绍,我们可以知道互斥同步最大的性能消耗在与对阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态去完成,这样会给系统的并发性能带来很大的压力。实际上,通常共享数据的锁定状态只会持续很短的时间,为了避免这段时间的开销而引进挂起和恢复的开销,是有些得不偿失的。

    为了解决上诉的问题,自旋锁这项技术就出现了。它使后面(时间概念上)请求锁的线程忙循环(即自旋)一段时间(也就是等待一段时间),而不是直接放弃处理器的执行时间去挂起,看看持有锁的线程能否很快的释放锁。

    试想一下,如果持有锁的线程一直不释放锁,那另外一个线程就要一直忙循环(即自旋)下去,这样该线程也将一直占用处理器的执行时间,被占用的处理器也就无法去处理其他线程了,这样肯定是不适合的。所以对于自旋的时间是要有限制的,如果超过了这个时间限制(实际上是自旋的次数)还没有获得锁,那就还是采用传统的方式将线程挂起。

    默认情况下自旋次数为10,我们可以通过参数-XX:PreBlockSpin来修改自旋次数。

  2. 自适应的自旋锁

    在JDK1.6中还引入了自适应的自旋锁。

    所谓自适应就是自旋时间不再固定,自旋时间是通过上一次获取该锁的自旋时间以及该锁拥有者的状态来决定

    我们假设【线程A】想去获取一个锁对象,该锁刚刚被【线程B】通过自旋等待获得,且【线程B】正在执行中,那么虚拟机就会认为【线程A】通过自旋等待也可以成功获得该锁,进而虚拟机会允许【线程A】的自旋等待时间更长,比如100次循环。

    我们再假设另外一个场景:某个锁,通过自旋很少成功获得,结果将如何呢?答案是:再之后获取锁的时候会直接省去自旋的过程,避免造成处理器资源的浪费。

13.3.2 锁消除

在之前的《深入理解JAVA虚拟机(第2版)》- 第11章 - 学习笔记》笔记中在总结基于逃逸分析结果会有哪些优化的时候,提到的同步消除就是锁消除

锁消除是指虚拟机的即时编译器在运行时,对那些代码上要求同步,但被检测到不存在共享数据竞争的锁进行消除。

13.3.3 锁粗化

虚拟机如果发现一串操作都是对同一个对象进行加锁,会将加锁同步的范围到扩展(粗化)至整个操作序列的外部,这样加一次锁就可以了

以下边的代码段为例:

​​public String concatString(String s1, String s2, String s3) {StringBuffer sb = new StringBuffer();sb.append(s1); // 标注①sb.append(s2); // 标注②sb.append(s3); // 标注③return sb.toString();
}​​

StringBuffer的append()方法是被关键字synchronized修饰的。代码段中的标注①、标注②、标注③,这一串操作都是第对象sb加锁,所以最终会将加锁同步的范围扩展为sb.append(s1)操作之前直到sb.append(s3)操作之后。

13.3.4 轻量级锁
  1. JDK1.6中引入的一项锁优化。

  2. 轻量级锁不是为了替代重量级锁而出现的,它是为了在不存在多线程竞争的情况下,减少重量级锁使用系统互斥量所带来的性能消耗。

  3. 谈轻量级锁和后边的偏向锁的时候,我们是绕不开实现它们的关键——对象头(Object Head)。HotSpot虚拟机的对象头(Object Head)有两部分组成:

    • 存储对象自身的运行时数据,例如:对象的HashCode、GC分代年龄,这部分数据官方称为Mark Word,它的长度在32位和64位虚拟机中分别为32bit和64bit。
    • 存储指向方法区的对象的类型数据指针。
    • 如果对象是数组的话,还需要有额外的部分用来存储数组的长度。
  4. 考虑虚拟机到空间效率问题,Mark Word被设计成一个非固定的数据结构,即根据对象当前所处的不同状态,存储的数据也不同,如下:

    状态锁标志位存储的数据
    未锁定01对象HashCode、GC分代年龄
    轻量级锁定00指向锁记录(Lock Record)的指针
    重量级锁定(膨胀)10指向重量级锁(互斥量)的指针
    GC标记11空(不需要记录信息)
    可偏向01偏向线程ID、偏向时间戳、GC分代年龄

    让我们来看下一个处于未锁定状态的对象在32位虚拟机中的它的Mark Word内存布局是什么样的,如下:
    在这里插入图片描述

  5. 轻量级锁的加锁过程,如下图: 在这里插入图片描述
    我们再具体的看下CAS操作前后Lock Record和对象的状态变化,如下图: 在这里插入图片描述

  6. 轻量级锁的解锁过程(解锁过程和加锁过程一样都是通过CAS操作来进行的):如果对象的Mark Word仍然指向Lock Record,那就使用CAS操作将Lock Record中的Displace Mark Word替换掉此时对象的Mark Word。如果替换成功,则整个同步过程完成。如果失败,则说明有其他线程尝试获取锁,那么后续就要在释放锁的同时唤醒被挂起的线程。

13.3.5 偏向锁
  1. JDK1.6引入的一项锁优化机制。

  2. 偏向锁会偏向于获取该锁的第一个线程,如果后续没有其他线程来获取该锁,则持有偏向锁的线程将永远不需要再进行同步。

  3. 偏向锁的原理是:

    假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是JDK 1.6的默认值)。

    当对象第一次被线程获取的时候,将对象头中的锁标志位设置为“01”,即偏向模式。同时将获取该对象锁的线程ID通过CAS操作记录在对象的Mark Word中。如果CAS操作成功,则持有该锁的线程以后每次进入该锁相关的同步块时,虚拟机都不会进行任何同步措施(例如:Locking、Unlocking以及Mark Word的Update操作)。

    当有另外的线程获取该锁时,则偏向模式结束。根据锁对象当前是否处于被锁定状态,撤销偏向锁后恢复到未锁定状态或轻量级锁的状态。

13.3.6 锁升级

对于一个重量级锁,通过锁优化我们了解到,它其实是一个升级的过程:偏向锁 -> 轻量级锁 -> 重量级锁,而不是不管什么情况都直接采用重量级锁(互斥同步)。

锁的升级过程:对象锁只有一个线程持有的时候,这个锁是偏向锁。当有两个以上的线程交替持有该锁的时候,此时锁是轻量级锁。当发生两个以上的线程同时要持有锁的时候(即并发获取锁),此时才会升级为重量级锁。

上一篇:《深入理解JAVA虚拟机(第2版)》- 第12章 - 学习笔记
下一篇:无(本篇为最终章)

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

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

相关文章

想从事大模型?一大波工作岗位等你选!

技术类岗位 您可从事&#xff1a;算法工程师&#xff0c;研发工程师。 管理类岗位 您可从事&#xff1a;AI项目经理、AI产品经理、AI销售、AI解决方案。 01 技术类岗位 算法工程师 大模型算法工程师的职位通常要求求职者具备以下几方面的能力和经验。 通用技能教育背景…

大健康管理系统|健康综合咨询问诊平台设计与实现(源码+数据库+文档)

大健康管理系统目录 目录 健康综合咨询问诊平台设计与实现 一、前言 二、系统功能设计 三、系统实现 5.1用户信息管理 5.2 医生信息管理 5.3科室信息管理 5.1新闻信息管理 四、数据库设计 1、实体ER图 2、具体的表设计如下所示&#xff1a; 五、核心代码 六、论文…

干货|生成式人工智能大模型备案详细办理资料清单

我以刚通过的大模型备案提交的材料清单给大家详细讲一讲 刚通过的大模型备案材料清单 1、安全评估报告 安全评估报告是大模型备案过程中的一个重要关键部分&#xff0c;它需由专业机构或团队完成&#xff0c;全面评估大模型在语料处理、模型训练、服务提供等环节中的安全性。…

vue3ScrollSeamless滚动如何给子元素添加点击事件:事件委托

页面布局如上截图 下面是方法 function parentClick(e) {if (e.target.tagName A) {router.push({path: /noticeDetails,query: {id: e.target.dataset.eid}});} }使用的时候&#xff0c;可以打印一下方法里面的e&#xff0c;加深理解

基于51单片机的电机控制和角度检测

目录 一、主要功能 二、硬件资源 三、程序编程 四、实现现象 一、主要功能 基于51单片机&#xff0c;采用滑动变阻器连接ADC0832数模转换器模拟角度传感器&#xff0c;然后通过LCD1602显示数值&#xff0c;然后按键按下不动&#xff0c;电机正转&#xff0c;松开停止。第二…

红外绝缘子数据集

红外绝缘子数据集&#xff0c;绝缘子旋转框检测 电气工程专业研究可用 电力领域稀有红外图像数据集 红外图像总数5000多张&#xff0c;txt格式&#xff0c;可直接用于yolo训练 红外绝缘子数据集 (Infrared Insulator Dataset, IID) 数据集描述 IID是一个专为电气工程专业研究…

react中解析markdown文本

背景 产品想把从某个地方复制出来的markdown文本&#xff0c;保存下来&#xff0c;并且在前端这边展示的时候&#xff0c;按照对应的格式展示 工具 markedhighlight.jsmarked-highlight 原来的marked版本&#xff0c;是可以直接处理高亮配置&#xff0c;但是后续更新为了轻量…

耦合微带线单元的网络参量和等效电路公式推导

文档下载链接&#xff1a;耦合微带线单元的网络参量和等效电路资源-CSDN文库https://download.csdn.net/download/lu2289504634/89583027笔者水平有限&#xff0c;错误之处欢迎留言&#xff01; 一、耦合微带线奇偶模详细推导过程 二、2,4端口开路 三、2端口短路、3端口开路 四…

Apple Intelligence预计会在iOS 18.1和iOS 18.4之间按此顺序推出

本月早些时候 iOS 18 已公开发布&#xff0c;但首批 Apple Intelligence 功能要等到 10 月份 iOS 18.1 发布后才可以使用。Apple Intelligence 功能将继续在 iOS 18.2 及更高版本中推出&#xff0c;预计路线图如下&#xff0c;出自 Apple 网站和传闻。 Apple Intelligence 需要…

electron-vite使用vue-i18n,ts 检查报错上不存在属性“$t”

问题&#xff1a; electron-vite使用vue-i18n&#xff0c;ts类型检查报错&#xff0c;但实际运行没有问题 解决方案&#xff1a; 1. 在electron-vite渲染端代码src目录下&#xff0c;增加 vue-i18n.d.ts 文件&#xff0c;添加如下内容&#xff1a; /* eslint-disable */ im…

【d47_2】【Java】【力扣】1791.找出星型图的中心节点

思路 直接判断 edges[0][0] edges[0][1] edges[1][0] edges[1][1] 谁重复了 例如&#xff1a; [ [1,2] [2,3] ....],那么中心节点一定是2 代码 class Solution {public int findCenter(int[][] edges) {for (int i0;i<1;i){if (edges[1][0]edges[0][i]) {return edg…

Java/Spring项目的包开头为什么是com?

Java/Spring项目的包开头为什么是com&#xff1f; 下面是一个使用Maven构建的项目初始结构 src/main/java/ --> Java 源代码com.example/ --->为什么这里是com开头resources/ --> 资源文件 (配置、静态文件等)test/java/ --> 测试代码resourc…

3D建模:Agisoft Metashape Professional 详细安装教程分享 Mac/win

Agisoft Metashape中文版&#xff08;以前称为 PhotoScan&#xff09;是一款独立软件产品&#xff0c;可对数字图像进行摄影测量处理并生成 3D 空间数据&#xff0c;用于 GIS 应用程序、文化遗产文献和视觉效果制作以及各种比例的物体的间接测量。 明智地实施数字摄影测量技术…

Windows 离线安装显示驱动

下载驱动人生&#xff0c;查看需要安装的驱动版本 驱动人生 驱动人生官网-显卡驱动_打印机驱动_网卡驱动_声卡驱动等驱动程序下载及检测平台驱动人生是一款提供电脑驱动下载和安装自动化的软件&#xff0c;通过驱动人生可一键安装显卡驱动、网卡驱动、声卡驱动、打印机驱动、…

Unity中Rigidbody 刚体组件和Rigidbody类是什么?

Rigidbody 刚体组件 Rigidbody 是 Unity 中的一个组件&#xff0c;它可以让你的游戏对象像真实世界中的物体一样移动和碰撞。想象一下&#xff0c;你有一个小球&#xff0c;你希望它像真实世界中的球一样滚动、弹跳和碰撞&#xff0c;那么你就可以给这个小球添加一个 Rigidbod…

R语言中的shiny框架

R语言中的shiny框架 Shiny 的基本概念基本用法示例常见用法示例1. 输入控件2. 输出控件3. 动态 UI4. 数据传递和反应式编程 高级功能1. 使用 shinyjs2. 使用 shinythemes Shiny 是一个 R 语言的框架&#xff0c;用于构建交互式的网页应用&#xff0c;可以让用户以最少的 HTML、…

uniapp出现 下拉框等组件被遮挡 的分析

目录 1. 问题所示2. 代码复现3. 解决方法3.1 下拉框被遮挡3.2 uni-collapse-item 无法下拉的问题 1. 问题所示 下拉框被遮挡的问题&#xff1a; uni-collapse-item组件无法下拉的问题&#xff1a; 2. 代码复现 博主的代码精简如下&#xff1a; <template><view>…

离职员工客户如何管理?解锁2024企业微信新功能

公司里员工来来去去很正常&#xff0c;但每次有人走&#xff0c;老板们都会头疼&#xff0c;因为客户信息得有人接着管。客户对公司来说太重要了&#xff0c;不能丢。2024年&#xff0c;企业微信出了个新招&#xff0c;就是员工离职后&#xff0c;客户信息可以轻松转给新来的员…

kalman滤波三:时序数据预测(一维kalman滤波)

文章目录 原理kalman滤波一&#xff1a;基础理论kalman滤波二&#xff1a;二维目标跟踪 一维kalman滤波状态变量测量值状态转移矩阵 示例代码&#xff1a;运行结果&#xff1a;真实场景示例 以下是一个测距的应用&#xff0c;在图像上计算目标离参考点的距离&#xff0c;测距的…

如何删除链表的中间节点和a/b处的节点?

文章目录 删除中间节点删除 a/b 处的节点 示例定义链表节点结构删除中间节点删除 a/b 处的节点 注意事项 Python 实现案例创建链表删除中间节点删除 a/b 处的节点测试代码示例代码完整版测试输出 在链表中删除中间节点或者特定位置&#xff08;如 a/b 处&#xff09;的节点涉及…