C#要点技术(一) - List 底层源码剖析

news2024/11/25 12:28:20

1。 ## 常用组件底层代码解析

List 底层代码剖析

List是一个C#中最常见的可伸缩数组组件,我们常常用它来替代数组,因为它是可伸缩的,所以我们在写的时候不用手动去分配数组的大小。甚至有时我们也会拿它当链表使用。那么到底它的底层是怎么编写的呢,每次增加和减少以及赋值,内部是怎么执行和运作的呢?我们接下来就来详细的讲解。

我们首先来看看List的构造部分,源码如下:


public class List<T> : IList<T>, System.Collections.IList, IReadOnlyList<T>
{
    private const int _defaultCapacity = 4;

    private T[] _items;
    private int _size;
    private int _version;
    private Object _syncRoot;
    
    static readonly T[]  _emptyArray = new T[0];        
        
    // Constructs a List. The list is initially empty and has a capacity
    // of zero. Upon adding the first element to the list the capacity is
    // increased to 16, and then increased in multiples of two as required.
    public List() {
        _items = _emptyArray;
    }

    // Constructs a List with a given initial capacity. The list is
    // initially empty, but will have room for the given number of elements
    // before any reallocations are required.
    // 
    public List(int capacity) {
        if (capacity < 0) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
        Contract.EndContractBlock();

        if (capacity == 0)
            _items = _emptyArray;
        else
            _items = new T[capacity];
    }

    //...
    //其他内容
}

从源码中可以知道,List 继承于IList,IReadOnlyList,IList是提供了主要的接口,IReadOnlyList提供了迭代接口。

IList源码

IReadOnlyList源码

看构造部分,我们明确了,List内部是用数组实现的,而不是链表,并且当没有给予指定容量时,初始的容量为0。

也就是说,我们可以大概率推测List组件在Add,Remove两个函数调用时都采用的是“从原数组拷贝生成到新数组”的方式工作的。

下面我们来看下,我们的猜测是否正确。

Add接口源码:

// Adds the given object to the end of this list. The size of the list is
// increased by one. If required, the capacity of the list is doubled
// before adding the new element.
//
public void Add(T item) {
    if (_size == _items.Length) EnsureCapacity(_size + 1);
    _items[_size++] = item;
    _version++;
}

// Ensures that the capacity of this list is at least the given minimum
// value. If the currect capacity of the list is less than min, the
// capacity is increased to twice the current capacity or to min,
// whichever is larger.
private void EnsureCapacity(int min) {
    if (_items.Length < min) {
        int newCapacity = _items.Length == 0? _defaultCapacity : _items.Length * 2;
        // Allow the list to grow to maximum possible capacity (~2G elements) before encountering overflow.
        // Note that this check works even when _items.Length overflowed thanks to the (uint) cast
        if ((uint)newCapacity > Array.MaxArrayLength) newCapacity = Array.MaxArrayLength;
        if (newCapacity < min) newCapacity = min;
        Capacity = newCapacity;
    }
}

上述List源代码中的Add函数,每次增加一个元素的数据,Add接口都会首先检查的是容量还够不够,如果不够则用 EnsureCapacity 来增加容量。

在 EnsureCapacity 中,有这样一行代码:

    int newCapacity = _items.Length == 0? _defaultCapacity : _items.Length * 2;

每次容量不够的时候,整个数组的容量都会扩充一倍,_defaultCapacity 是容量的默认值为4。因此整个扩充的路线为4,8,16,32,64,128,256,512,1024…依次类推。

List使用数组形式作为底层数据结构,好处是使用索引方式提取元素很快,但在扩容的时候就会很糟糕,每次new数组都会造成内存垃圾,这给垃圾回收GC带来了很多负担。

这里按2指数扩容的方式,可以为GC减轻负担,但是如果当数组连续被替换掉也还是会造成GC的不小负担,特别是代码中List频繁使用的Add时。另外,如果数量不得当也会浪费大量内存空间,比如当元素数量为 520 时,List 就会扩容到1024个元素,如果不使用剩余的504个空间单位,就造成了大部分的内存空间的浪费。具体该怎么做才是最佳的策略,我们将在后面的文章中讨论。

我们再来看看Remove接口部分的源码:


// Removes the element at the given index. The size of the list is
// decreased by one.
// 
public bool Remove(T item) {
    int index = IndexOf(item);
    if (index >= 0) {
        RemoveAt(index);
        return true;
    }

    return false;
}

// Returns the index of the first occurrence of a given value in a range of
// this list. The list is searched forwards from beginning to end.
// The elements of the list are compared to the given value using the
// Object.Equals method.
// 
// This method uses the Array.IndexOf method to perform the
// search.
// 
public int IndexOf(T item) {
    Contract.Ensures(Contract.Result<int>() >= -1);
    Contract.Ensures(Contract.Result<int>() < Count);
    return Array.IndexOf(_items, item, 0, _size);
}

// Removes the element at the given index. The size of the list is
// decreased by one.
// 
public void RemoveAt(int index) {
    if ((uint)index >= (uint)_size) {
        ThrowHelper.ThrowArgumentOutOfRangeException();
    }
    Contract.EndContractBlock();
    _size--;
    if (index < _size) {
        Array.Copy(_items, index + 1, _items, index, _size - index);
    }
    _items[_size] = default(T);
    _version++;
}

Remove接口中包含了 IndexOf 和 RemoveAt,其中用 IndexOf 函数是位了找到元素的索引位置,用 RemoveAt 可以删除指定位置的元素。

从源码中我们可以看到,元素删除的原理其实就是用 Array.Copy 对数组进行覆盖。IndexOf 启用的是 Array.IndexOf 接口来查找元素的索引位置,这个接口本身内部实现是就是按索引顺序从0到n对每个位置的比较,复杂度为O(n)。

先补急着总结,我们再看来 Insert 接口源码。


// Inserts an element into this list at a given index. The size of the list
// is increased by one. If required, the capacity of the list is doubled
// before inserting the new element.
// 
public void Insert(int index, T item) {
    // Note that insertions at the end are legal.
    if ((uint) index > (uint)_size) {
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_ListInsert);
    }
    Contract.EndContractBlock();
    if (_size == _items.Length) EnsureCapacity(_size + 1);
    if (index < _size) {
        Array.Copy(_items, index, _items, index + 1, _size - index);
    }
    _items[index] = item;
    _size++;            
    _version++;
}

与Add接口一样,先检查容量是否足够,不足则扩容。从源码中获悉,Insert插入元素时,使用的用拷贝数组的形式,将数组里的指定元素后面的元素向后移动一个位置。

看到这里,可以我们明白了List的Add,Insert,IndexOf,Remove接口都是没有做过任何形式的优化,都使用的是顺序迭代的方式,如果过于频繁使用的话,会导致效率降低,也会造成不少内存的冗余,使得垃圾回收(GC)时承担了更多的压力。

其他相关接口比如 AddRange,RemoveRange的原理和Add与Remove一样,区别只是多了几个元素,把单个元素变成了以容器为单位的形式进行操作。都是先检查容量是否合适,不合适则扩容,或者当Remove时先得到索引位置再进行整体的覆盖掉后面的的元素,容器本身大小不会变化,只是做了重复覆盖的操作。

其他接口也同样基于数组,并使用了类似的方式来对数据做操作,我们可以来快速的看看其他常用接口的源码是如何实现的。

比如 []的实现,


// Sets or Gets the element at the given index.
// 
public T this[int index] {
    get {
        // Following trick can reduce the range check by one
        if ((uint) index >= (uint)_size) {
            ThrowHelper.ThrowArgumentOutOfRangeException();
        }
        Contract.EndContractBlock();
        return _items[index]; 
    }

    set {
        if ((uint) index >= (uint)_size) {
            ThrowHelper.ThrowArgumentOutOfRangeException();
        }
        Contract.EndContractBlock();
        _items[index] = value;
        _version++;
    }
}

[]的实现,直接使用了数组的索引方式获取元素。

再比如 Clear 清除接口

// Clears the contents of List.
public void Clear() {
    if (_size > 0)
    {
        Array.Clear(_items, 0, _size); // Don't need to doc this but we clear the elements so that the gc can reclaim the references.
        _size = 0;
    }
    _version++;
}

Clear接口在调用时并不会删除数组,而只是将数组中的元素清零,并设置 _size 为 0 而已,用于虚拟地表明当前容量为0。

再比如 Contains 接口,用于确实某元素是否存在于List中

// Contains returns true if the specified element is in the List.
// It does a linear, O(n) search.  Equality is determined by calling
// item.Equals().
//
public bool Contains(T item) {
    if ((Object) item == null) {
        for(int i=0; i<_size; i++)
            if ((Object) _items[i] == null)
                return true;
        return false;
    }
    else {
        EqualityComparer<T> c = EqualityComparer<T>.Default;
        for(int i=0; i<_size; i++) {
            if (c.Equals(_items[i], item)) return true;
        }
        return false;
    }
}

从源代码中我们可以看到,Contains 接口使用的是线性查找方式比较元素,对数组进行迭代,比较每个元素与参数的实例是否一致,如果一致则返回true,全部比较结束还没有找到,则认为查找失败。

再比如 ToArray 转化数组接口

// ToArray returns a new Object array containing the contents of the List.
// This requires copying the List, which is an O(n) operation.
public T[] ToArray() {
    Contract.Ensures(Contract.Result<T[]>() != null);
    Contract.Ensures(Contract.Result<T[]>().Length == Count);

    T[] array = new T[_size];
    Array.Copy(_items, 0, array, 0, _size);
    return array;
}

ToArray接口中,重新new了一个指定大小的数组,再将本身数组上的内容考别到新数组上,再返回出来。

再比如 Find 查找接口

public T Find(Predicate<T> match) {
    if( match == null) {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match);
    }
    Contract.EndContractBlock();

    for(int i = 0 ; i < _size; i++) {
        if(match(_items[i])) {
            return _items[i];
        }
    }
    return default(T);
}

Find接口使用的同样是线性查找,对每个元素都进行了比较,复杂度为O(n)。

再比如 Enumerator 枚举迭代部分的细节

// Returns an enumerator for this list with the given
// permission for removal of elements. If modifications made to the list 
// while an enumeration is in progress, the MoveNext and 
// GetObject methods of the enumerator will throw an exception.
//
public Enumerator GetEnumerator() {
    return new Enumerator(this);
}

/// <internalonly/>
IEnumerator<T> IEnumerable<T>.GetEnumerator() {
    return new Enumerator(this);
}

System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() {
    return new Enumerator(this);
}

[Serializable]
public struct Enumerator : IEnumerator<T>, System.Collections.IEnumerator
{
    private List<T> list;
    private int index;
    private int version;
    private T current;

    internal Enumerator(List<T> list) {
        this.list = list;
        index = 0;
        version = list._version;
        current = default(T);
    }

    public void Dispose() {
    }

    public bool MoveNext() {

        List<T> localList = list;

        if (version == localList._version && ((uint)index < (uint)localList._size)) 
        {                                                     
            current = localList._items[index];                    
            index++;
            return true;
        }
        return MoveNextRare();
    }

    private bool MoveNextRare()
    {                
        if (version != list._version) {
            ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
        }

        index = list._size + 1;
        current = default(T);
        return false;                
    }

    public T Current {
        get {
            return current;
        }
    }

    Object System.Collections.IEnumerator.Current {
        get {
            if( index == 0 || index == list._size + 1) {
                 ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumOpCantHappen);
            }
            return Current;
        }
    }

    void System.Collections.IEnumerator.Reset() {
        if (version != list._version) {
            ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
        }
        
        index = 0;
        current = default(T);
    }

}

其中我们需要注意 Enumerator 这个结构,每次获取迭代器时,Enumerator 每次都是被new出来,如果大量使用迭代器的话,比如foreach就会造成大量的垃圾对象,这也是为什么我们常常告诫程序员们,尽量不要用foreach,因为 List 的 foreach 会增加有新的 Enumerator 实例,最后由GC垃圾回收掉。

最后我们来看看 Sort 排序接口

// Sorts the elements in a section of this list. The sort compares the
// elements to each other using the given IComparer interface. If
// comparer is null, the elements are compared to each other using
// the IComparable interface, which in that case must be implemented by all
// elements of the list.
// 
// This method uses the Array.Sort method to sort the elements.
// 
public void Sort(int index, int count, IComparer<T> comparer) {
    if (index < 0) {
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
    }
    
    if (count < 0) {
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.count, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
    }
        
    if (_size - index < count)
        ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_InvalidOffLen);
    Contract.EndContractBlock();

    Array.Sort<T>(_items, index, count, comparer);
    _version++;
}

它使用了 Array.Sort接口进行排序,其中Array.Sort的源码我们也把它找出来。以下为 Array.Sort 的使用的算法源码:


internal static void DepthLimitedQuickSort(T[] keys, int left, int right, IComparer<T> comparer, int depthLimit)
{
    do
    {
        if (depthLimit == 0)
        {
            Heapsort(keys, left, right, comparer);
            return;
        }

        int i = left;
        int j = right;

        // pre-sort the low, middle (pivot), and high values in place.
        // this improves performance in the face of already sorted data, or 
        // data that is made up of multiple sorted runs appended together.
        int middle = i + ((j - i) >> 1);
        SwapIfGreater(keys, comparer, i, middle);  // swap the low with the mid point
        SwapIfGreater(keys, comparer, i, j);   // swap the low with the high
        SwapIfGreater(keys, comparer, middle, j); // swap the middle with the high

        T x = keys[middle];
        do
        {
            while (comparer.Compare(keys[i], x) < 0) i++;
            while (comparer.Compare(x, keys[j]) < 0) j--;
            Contract.Assert(i >= left && j <= right, "(i>=left && j<=right)  Sort failed - Is your IComparer bogus?");
            if (i > j) break;
            if (i < j)
            {
                T key = keys[i];
                keys[i] = keys[j];
                keys[j] = key;
            }
            i++;
            j--;
        } while (i <= j);

        // The next iteration of the while loop is to "recursively" sort the larger half of the array and the
        // following calls recrusively sort the smaller half.  So we subtrack one from depthLimit here so
        // both sorts see the new value.
        depthLimit--;

        if (j - left <= right - i)
        {
            if (left < j) DepthLimitedQuickSort(keys, left, j, comparer, depthLimit);
            left = i;
        }
        else
        {
            if (i < right) DepthLimitedQuickSort(keys, i, right, comparer, depthLimit);
            right = j;
        }
    } while (left < right);
}

Array.Sort 使用的是快速排序方式进行排序,从而我们明白了 List 的 Sort 排序的效率为O(nlogn)。

我们把大部分的接口都列了出来,差不多把所有的源码都分析了一遍,我们可以看到 List 的效率并不高,只是通用性强而已,大部分的算法都使用的是线性复杂度的算法,这种线性算法当遇到规模比较大的计算量级时就会导致CPU的大量损耗。
我们可以自己改进它,比如不再使用有线性算法的接口,自己重写一套,但凡要优化List 中的线性算法的地方都使用,我们自己制作的工具类。
List的内存分配方式也极为不合理,当List里的元素不断增加时,会多次重新new数组,导致原来的数组被抛弃,最后当GC被调用时造成回收的压力。
我们可以提前告知 List 对象最多会有多少元素在里面,这样的话 List 就不会因为空间不够而抛弃原有的数组,去重新申请数组了。

List源码

另外我们也可以从源码上看得出,代码是线程不安全的,它并没有对多线程下做任何锁或其他同步操作。并发情况下,无法判断 _size++ 的执行顺序,因此当我们在多线程间使用 List 时加上安全机制。

最后List 并不是高效的组件,真实情况是,他比数组的效率还要差的多,他只是个兼容性比较强得组件而已,好用,但效率差。

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

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

相关文章

将Quazip编译成基于32位release版的库时报错的解决方案

开发环境&#xff1a;Win10 Qt5.9.9 注意&#xff1a;阅读本篇文章前&#xff0c;首先阅读Quazip的编译及使用&#xff0c;保姆级教程。 之前写了如何编译Quazip的库&#xff0c;当时是使用MSV2015-64-release来编译的&#xff0c; 具体编译流程可参考之前的文章Quazip的编译及…

3句代码,实现自动备份与版本管理

前言&#xff1a;服务器开发程序、测试版本等越来越多&#xff0c;需要及时做好数据的版本管理和备份&#xff0c;作为21世界的青年&#xff0c;希望这些事情都是可以自动完成&#xff0c;不止做了数据备份&#xff0c;更重要的是做好了版本管理&#xff0c;让我们可以追溯我们…

用Go快速搭建IM即时通讯系统

WebSocket的目标是在一个单独的持久连接上提供全双工、双向通信。在Javascript创建了Web Socket之后&#xff0c;会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后&#xff0c;建立的连接会将HTTP升级从HTTP协议交换为WebSocket协议。由于WebSocket使用自定义的协议…

深度学习部署笔记(十): CUDA RunTime API-2.2流的学习

1. 流的定义 流&#xff08;Stream&#xff09;是一个基于上下文&#xff08;Context&#xff09;的任务管道抽象&#xff0c;是一组由GPU依次执行的CUDA操作序列&#xff0c;其中每个操作可能会使用或产生数据。在一个上下文中可以创建多个流&#xff0c;每个流都拥有自己的任…

Kettle体系结构及源码解析

介绍 ETL是数据抽取&#xff08;Extract&#xff09;、转换&#xff08;Transform&#xff09;、装载&#xff08;Load&#xff09;的过程。Kettle是一款国外开源的ETL工具&#xff0c;有两种脚本文件transformation和job&#xff0c;transformation完成针对数据的基础转换&…

全网最详细的(CentOS7)MySQL安装

一、环境介绍 操作系统&#xff1a;CentOS 7 MySQL&#xff1a;5.7 二、MySQL卸载 查看软件 rpm -qa|grep mysql 卸载MySQL yum remove -y mysql mysql-libs mysql-common rm -rf /var/lib/mysql rm /etc/my.cnf 查看是否还有 MySQL 软件&#xff0c;有的话继续删除。 软件卸…

单线程的 javascript 如何管理任务

要怎么理解 JavaScript 是单线程这个概念呢&#xff1f;大概需要从浏览器来说起。 JavaScript 最初被设计为浏览器脚本语言&#xff0c;主要用途包括对页面的操作、与浏览器的交互、与用户的交互、页面逻辑处理等。如果将 JavaScript 设计为多线程&#xff0c;那当多个线程同时…

Excel职业版本(4)

图表 图表基本结构 组成元素 图表的分类 柱状图 介绍&#xff1a;在竖直方向比较不同类型的数据 适用场景&#xff1a;用于二维数据集&#xff0c;对于不同类型的数据进行对比&#xff0c;也可用于同一类型的数据在不同的时间维度的数据对比&#xff0c;通过柱子的高度来反…

GeniE 实用教程(五)荷载与边界

目 录一、前言二、位移边界三、工况与组合3.1 荷载工况3.2 荷载组合四、自重/设备/隔间4.1 结构自重4.2 设备荷载4.3 隔间负载五、显式荷载六、环境荷载6.1 点位信息 / Location6.2 波浪数据 / Wave6.2.1 规则波数据6.2.2 一般波数据6.3 洋流廓线 / Current Profile6.4 风轮廓线…

【物联网低功耗转接板】+机智云开发体验之遥控灯

在本文中&#xff0c;通过设计一个智能遥控的小灯来介绍一下使用机智云平台的开发过程和体验。一、硬件设计设计硬件电路之前&#xff0c;我先查阅了GE211的规格书&#xff0c;发现预留接口是5V电平。翻找了一下手头的板卡&#xff0c;发现只有一块arduino UNO是5V电平的。因此…

Linux驱动的同步阻塞和同步非阻塞

在字符设备驱动中&#xff0c;若要求应用与驱动同步&#xff0c;则在驱动程序中可以根据情况实现为阻塞或非阻塞一、同步阻塞这种操作会阻塞应用程序直到设备完成read/write操作或者返回一个错误码。在应用程序阻塞这段时间&#xff0c;程序所代表的进程并不消耗CPU的时间&…

buu RSA 1 (Crypto 第一页)

题目描述&#xff1a; 两个文件&#xff0c;都用记事本打开&#xff0c;记住用记事本打开 pub.key: -----BEGIN PUBLIC KEY----- MDwwDQYJKoZIhvcNAQEBBQADKwAwKAIhAMAzLFxkrkcYL2wch21CM2kQVFpY97 /AvKr1rzQczdAgMBAAE -----END PUBLIC KEY-----flag.enc: A柪YJ^ 柛x秥?y…

Vue中 $attrs、$listeners 详解及使用

$attrs 用于父组件隔代向孙组件传值 $ listeners用于孙组件隔代向父组件传值 这两个也可以同时使用&#xff0c;达到父组件和孙组件双向传值的目的。 A组件&#xff08;App.vue&#xff09; <template><div id"app"><!-- 此处监听了两个事件&…

前端包管理工具:npm,yarn、cnpm、npx、pnpm

包管理工具npm Node Package Manager&#xff0c;也就是Node包管理器&#xff1b; 但是目前已经不仅仅是Node包管理器了&#xff0c;在前端项目中我们也在使用它来管理依赖的包&#xff1b; 比如vue、vue-router、vuex、express、koa、react、react-dom、axios、babel、webpack…

描述性统计

参考文献 威廉 M 门登霍尔 《统计学》 文章目录定性数据的描述方法条形图饼图帕累托图定量数据点图茎叶图频数分布直方图MINITAB 工具在威廉《统计学》一书将统计学分为描述统计学和推断统计学&#xff0c;他们的定义分别如下&#xff1a;描述统计学&#xff1a;致力于数据集的…

人生又有几个四年

机缘 不知不觉&#xff0c;已经来 csdn 创作四周年啦~ 我是在刚工作不到一年的时候接触 csdn 的&#xff0c;当时在学习 node&#xff0c;对 node 的文件相关的几个 api 总是搞混&#xff0c;本来还想着在传统的纸质笔记本上记一下&#xff0c;但是想想我大学记了好久的笔记本…

1.Spring Cloud (Hoxton.SR8) 学习笔记—IDEA 创建 Spring Cloud、配置文件样例

本文目录如下&#xff1a;一、IDEA 创建 Spring Cloud 基本步骤创建父项目 (Project)创建子模块 (Module)Spring Cloud 中的依赖版本对应关系?Spring Cloud实现模块间相互调用(引入模块)&#xff1f;Maven项目命名规范&#xff08;groupID、artifactid&#xff09;Spring Clou…

如何使用码匠连接 MariaDB

MariaDB 是一个免费的、开源的关系型数据库管理系统&#xff0c;由 MariaDB 的创始人 Michael Widenius 于 2010 年创建。它基于 MariaDB&#xff0c;但在对数据存储的处理中加入了一些自己的特性。MariaDB 相对于 MariaDB 而言&#xff0c;具有更好的性能和更好的兼容性&#…

JavaWeb--案例(Axios+JSON)

JavaWeb--案例&#xff08;AxiosJSON&#xff09;1 需求2 查询所有功能2.1 环境准备2.2 后端实现2.3 前端实现2.4 测试3 添加品牌功能3.1 后端实现3.2 前端实现3.3 测试1 需求 使用Axios JSON 完成品牌列表数据查询和添加。页面效果还是下图所示&#xff1a; 2 查询所有功能 …

3年测试经验,10家企业面试,爆-肝整理软件测试面试题与市场需求......

目录&#xff1a;导读前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结&#xff08;尾部小惊喜&#xff09;前言 现在网上的软件测试…