[缓存] RingBuff 环形队列、环形缓冲区及有锁无锁实现

1. 环形缓存区概念

  • 环形缓存区(circular buffer)也称为环形队列(circular queue) 或者循环缓冲区,是一种数据结构,用于在固定大小的缓冲区中存储和处理数据。

  • 环形缓冲区对于数据写入和读出以不同速率发生的情况也是非常有用的结构:最新数据始终可用。如果读取数据的速度跟不上写入数据的速度,旧的数据将被新写入的数据覆盖。通过使用循环缓冲区,能够保证我们始终使用最新的数据。

2. 环形缓存区特点

    1. 循环存储:缓冲区尾部到达物理存储末尾时,覆盖开头的老的数据,形成循环。
    1. 简单高效:数据读取和存储都是顺序进行,时效性高。
    1. 固定大小:大小预定义,内存分配和管理更加可控。
    1. 快速读写:读写指针步长固定、无需频繁移动指针。

3. 环形缓存区应用

    1. 数据流处理: 处理连续的数据流,如音频、视频、传感器数据。通过循环利用缓存区的空间,可实现高效的数据流存储及快速的读取和处理。
    1. 缓冲数据传输:缓冲数据的传输,特别是生产者和消费者的传输场景。平衡生产者和消费者的速度差异,避免数据丢失,起到队列的作用。
    1. 数据采样和循环记录:适用于需要采样和记录连续数据的应用。例如嵌入式系统中的数据采集、实时监控系统中的历史数据记录等。
    1. 实时任务调度 :存储任务队列。任务按照优先级或者时间顺序排列,通过环形缓冲区的循环读取,可以高效地完成实时任务的调度和执行。
  • 总结:环形缓冲区适用于需要循环读写、高效利用内存和处理连续数据的场景,可以提高数据处理的效率和性能。

4. 环形缓存区代码实现

#include <iostream>
using namespace std;
template<typename T> class circlequeue{
private:
	unsigned int m_size;
	int m_front;
	int m_rear;
	T* m_data;
public:
	circlequeue(unsigned int size){
		m_front = m_rear = 0;
		m_data = new T[m_size = size];
	}
	~circlequeue(){
		delete[] m_data;
	}
	bool isEmpty(){
		return m_front == m_rear;
	}
	bool isFull(){
		//m_front与m_rear均会移动,%size来判断,比如size = 10,m_rear = 9, m_front = 0的情况,需要考虑环形回环
		return m_front == (m_rear + 1) % m_size;
	}
	void push(T data){
		if (isFull()){
			return;
		}
		m_data[m_rear] = data;
		m_rear = (m_rear + 1) % m_size;
	}
	void pop(){
		if (isEmpty()){
			return;
		}
		m_front = (m_front + 1) % m_size;
	}
	void popall(){
		if (isEmpty()){
			return;
		}
		while (m_front != m_rear)
			m_front = (m_front + 1) % m_size;
	}
	T top(){
		if (isEmpty()){
			throw "no data to pop";
		}
		return m_data[m_front];
	}
};
int main(){
	circlequeue<int> q(5);
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);
	for (int i = 0; i < 4; i++){
		cout << q.top() << endl;
		q.pop();
	}
	q.push(5);
	q.push(5);
	q.push(5);
	cout << q.top() << endl;
	q.pop();
	cout << q.top() << endl;
	q.pop();
	cout << q.top() << endl;
	q.pop();
	q.push(5);
	q.push(5);
	q.push(5);
	q.popall();
	//cout << q.top() << endl;
	//q.pop();
	return 0;
}

5. 多线程pop和push 如何保证线程安全?

5.1 信号量保证线程安全

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
using namespace std;
 
const int BUFFER_SIZE = 10;
 
class CircularBuffer {
private:
  int buffer[BUFFER_SIZE];
  int readIndex;	// 队列头
  int writeIndex;	// 队列尾
  int itemCount; 	// 记录个数
  
  mutex mtx;
  condition_variable bufferFull;	// 同步原语,队列满,阻塞写线程
  condition_variable bufferEmpty;	// 同步原语,队列空,阻塞读线程
 
public:
  CircularBuffer() {
    readIndex = 0;
    writeIndex = 0;
    itemCount = 0;
  }
  bool isEmpty() {
    return (itemCount == 0);
  }
 
  bool isFull() {
    return (itemCount == BUFFER_SIZE);
  }
 
  void write(int data) {
    unique_lock<mutex> lock(mtx);  //加锁
    bufferFull.wait(lock, [this] { return !isFull(); });// 判断条件为true 继续,否则继续挂起 
 
    buffer[writeIndex] = data;
    writeIndex = (writeIndex + 1) % BUFFER_SIZE;
    itemCount++;
    cout << "Data " << data << " has been written to buffer." << endl;
 
    lock.unlock();//释放锁
    bufferEmpty.notify_one(); // 通知可以读
  }
 
  int read() {
    unique_lock<mutex> lock(mtx);	//加锁
    bufferEmpty.wait(lock, [this] { return !isEmpty(); });// 判断条件为true 继续,否则继续挂起 
 
    int data = buffer[readIndex];
    readIndex = (readIndex + 1) % BUFFER_SIZE;
    itemCount--;
    cout << "Data " << data << " has been read from buffer." << endl;
 
    lock.unlock();	// 释放锁
    bufferFull.notify_one(); // 通知一个等待的写线程
    return data;
  }
};
 
int main() {
  CircularBuffer buffer;
 
  thread writer([&buffer]() {
    for (int i = 1; i <= 5; i++) {
      buffer.write(i);
      this_thread::sleep_for(chrono::milliseconds(500));
    }
  });
  thread reader([&buffer]() {
    for (int i = 1; i <= 5; i++) {
      buffer.read();
      this_thread::sleep_for(chrono::seconds(1));
    }
  });
  writer.join();
  reader.join();
  return 0;
}

知识点一 条件变量 condition_variable:

  • 利用线程间共享的变量进行同步的一种机制,是在多线程程序中用来实现“等待-> 唤醒”逻辑常用的方法。可以利用条件变量来等待条件为真。
  • 条件不满足时,线程将自己加入等待队列,同时释放持有的互斥锁
  • 用法:
  1. void wait (unique_lock& lck); // 直接挂起并释放锁
  2. template void wait (unique_lock& lck, Predicate pred); // 若pred为false则对应线程挂起并释放锁,直到被唤醒,唤醒后再判断pred,若为false则继续挂起,直到被唤醒同时条件为true。
    相当于: while (!pred()) wait(lck);
  3. std::condition_variable::notify_one // 发送通知环形等待队列线程
  4. template <class Rep, class Period> cv_status wait_for (unique_lock& lck, const chrono::duration<Rep,Period>& rel_time); // 等待直到被唤醒或超时,返回值为超时与否。其同样有另一种重载形式,与(2)类似。

5.2 无锁队列前置概念

5.2.1 CAS原子操作

compare&set或者是compare&swap,CPU支持的原子操作,用来实现无锁数据结构。
含义:查看一下内存或者变量是否是oldval,如果是则更新成newval。

bool compare_and_swap (int*accum, int*dest, intnewval){
  if( *accum == *dest ) { //是老的值
      *dest = newval; // 更新成新的value
      return true;
  }
  return false;
}

与CAS相似的还有下面的原子操作:(这些东西大家自己看Wikipedia吧)

  • Fetch And Add,一般用来对变量做 +1 的原子操作
  • Test-and-set,写值到某个内存位置并传回其旧值。汇编指令BST
int TestAndSet (int *old_ptr, int new) {
    int old = *old_ptr;     // 存储old_ptr原始的值
    *old_ptr = new;         // 把新的值写到old_ptr
    return old;             // 返回原始的值
}
void lock (lock_t *mutex) {
    while (TestAndSet(&lock->falg, 1) == 1)
        ;       //自旋等待
    mutex->flag = 1;
}
如果锁之前被别的线程占有了,等待锁的线程会一次又一次地把flag设为1,只有锁被释放flag=0的时候,等待线程根据返回值知道自己获取了锁。
  • Test and Test-and-set,用来低低Test-and-Set的资源争夺情况

5.2.2 CAS示例

    1. 初始:内存中存储值为1
    1. 线程一:预期旧值1 期望值 2。 如果存储值与预期相同,则修改成功,获取锁。
    1. 线程二:将存储中值改成了5,则第2步中线程一获取锁失败。此时线程一预期旧值5 期望值6

以上可以看出,CAS是乐观锁,乐观的认为程序中并发情况不那么严重,让程序不断去尝试更新。

5.2.3 CAS中ABA问题

    1. 银行取款,账户有1000,取500,由于网络波动该操作重复了两次即,线程一旧值1000 期望 500 。线程二旧值1000 期望 500 。
    1. 由于原子性,只能执行一个线程的操作,加入执行了线程一。此时银行账户500。
    1. 恰好此时,银行账号被打入500,则线程二 在自旋过程中 判断获取锁,则执行成功。
  • 总结:这样本来取出来500 却被扣款两次,共计扣1000。

如何解决ABA问题呢? 加入版本号

    1. 初始账户 1000 版本号 1
    1. 线程一执行成功时候 账户 500 版本号 2
    1. 打入500时,账户1000 版本号 3
  • 4 线程二 要执行时候,虽然账户余额是1000 但是版本号不是1而是 3,则自旋失败。

5.2.4 CAS优缺点

优点

  • 并发量少 或者 对变量修改少的时候,效率会比传统加锁高。因为不涉及到用户态和内核态的切换。

缺点

  • 并发量大的时候,可能会因为变量一直更新而无法比较成功,进而不停的自旋,造成CPU压力过大
  • CAS只能保证一个变量的原子性,并不能保证整个代码块的原子性,处理多个变量的原子性更新时,还是得加锁

5.2.5 锁

互斥锁和自旋锁是最底层的两种锁,大部分高级锁都是基于这两种锁实现。

互斥锁

  • 互斥锁是一种睡眠锁,当一个线程占据了锁之后,其他加锁失败的线程都会进行睡眠。
  • 例如我们有A、B两个线程一同争抢互斥锁,当线程A成功抢到了互斥锁时,该锁就被他独占,在它释放锁之前,B的加锁操作就会失败,并且此时线程B将CPU让给其他线程,而自己则被阻塞。
  • 对于互斥锁加锁失败后进入阻塞的现象,由操作系统的内核实现
    在这里插入图片描述
  • 当加锁失败时,内核会将线程置为睡眠状态,并将CPU切换给其他线程运行。此时从用户态切换至内核态
  • 当锁被释放时,内核将线程至为就绪状态,然后在合适的时候唤醒线程获取锁,继续执行业务。此时从内核态切换至用户态

所以当互斥锁加锁失败的时候,就伴随着两次上下文切换的开销,而如果我们锁定的时间较短,可能上下文切换的时间会比锁定的时间还要长。

虽然互斥锁的使用难度较低,但是考虑到上下文切换的开销,在某些情况下我们还是会优先考虑自旋锁。

自旋锁

  • 自旋锁是基于CAS实现的,它在用户态完成了加锁和解锁的操作,不会主动进行上下文的切换,因此它的开销相比于互斥锁也会少一些。

  • 任何尝试获取该锁的线程都将一直进行尝试(即自旋),直到获得该锁,并且同一时间内只能由一个线程能够获得自旋锁。

自旋锁的本质其实就是对内存中一个整数的CAS操作,加锁包含以下步骤

  1. 查看整数的值,如果为0则说明锁空闲,则执行第二步,如果为1则说明锁忙碌,执行第三步
  2. 将整数的值设为1,当前线程进入临界区中
  3. 继续自旋检查(回到第一步),直到整数的值为0

从上面可以看出,对于获取自旋锁失败的线程会一直处于忙等待的情况,不断自旋直至获取锁资源,这也就要求我们必须要尽快释放锁,否则会占用大量的CPU资源

对比及应用场景

  • 由于自旋锁和互斥锁的失败策略不同,自旋锁采用忙等待的策略,而互斥锁采用线程切换的策略,由于策略不同,它们的应用场景也不同。

  • 由于自旋锁不需要进行线程切换,所以它完全在用户态下实现,加锁开销低,但是由于其采用忙等待的策略,对于短期加锁来说没问题,但是长期锁定的时候就会导致CPU资源的大量消耗。并且由于它不会睡眠,所以它可以应用于中断处理程序中。

  • 互斥锁采用线程切换的策略,当切换到别的线程的时候,原线程就会进入睡眠(阻塞)状态,所以如果对睡眠有要求的情况可以考虑使用互斥锁。并且由于睡眠不会占用CPU资源,在长期加锁中它比起自旋锁有极大的优势

读写锁

  • 核心:写独占、读共享
  • 读锁:是一个共享锁,没有写线程的时候,读锁可以被多个读线程并发持有。
  • 写锁:是一个独占锁,任何线程持有写锁,其他无论读线程和写线程都会被阻塞。
  • 实现方式:读写锁又分为读者优先、写者优先、读写公平

读者优先

  • 读者优先期望的是读锁能够被更多的线程持有,以提高读线程的并发性。
  • 为了做到这一点,它的规则如下:即使有线程申请了写锁,但是只要还有读者在读取内容,就允许其他的读线程继续申请读锁,而将申请写锁的进程阻塞,直到没有读线程在读时,才允许该线程写

写者优先

  • 写者优先则是优先服务于写进程
  • 假设此时有读线程已经持有读锁,正在读,而另一写线程申请了写锁,写线程被阻塞。为了能够保证写者优先,此时后来的读线程获取读锁时则会被阻塞。而当先前的读线程释放读锁时,写线程则进行写操作,直到写线程写完之前,其他的线程都会被阻塞。

读写公平

  • 从上面两个规则可以看出,读写优先都会导致另一方饥饿

  • 读者优先时,对于读进程并发性高,但是如果一直有都进程获取读锁,就会导致写进程永远获取不到写锁,此时就会导致写进程饥饿。

  • 写者优先时,虽然可以保证写进程不会饿死,但是如果一直有写进程获取写锁,导致读进程永远获取不到读锁,此时就会导致读进程饥饿。

  • 既然偏袒哪一方都会导致另一方被饿死,所以我们可以搞一个读写公平的规则

实现方式:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁,这样读线程仍然可以并发,也不会出现饥饿的情况。

读写锁 VS 互斥锁

  • 性能方面来说,读写锁的效率并不比互斥锁高。读锁加锁的开销并不比互斥锁小,因为它要实时维护当前读者的数量,在临界区很小,锁竞争不激烈的情况下,互斥锁的效率往往更快
  • 虽然读写锁在速度上可能不如互斥锁,但是并发性好,对于并发要求高的地方,应该优先考虑读写锁。

分布式锁(reids、zookeeper、DB锁-悲观锁和乐观锁)

  • 可重入锁:同一个对象可多次获取同一个锁,计数
  • 公平锁:读写公平,则创建一个请求队列,依次获取锁,ReentrantLock
  • 联锁 : 解决主从一致性,只有三个副本全部锁住才算获取锁。MultiLock
  • 红锁:Redis Distributed Lock;即使用redis实现的分布式锁 // 利用setnx 命令

5.3 无锁队列

5.3.1 生产者和消费者分类

  1. 单生产者和单消费者
  • 对于单生产者和单消费者场景,由于read_index和write_index都只会有一个线程写,因此不需要加锁也不需要原子操作,直接修改即可,但读写数据时需要考虑遇到数组尾部的情况。

线程对write_index和read_index的读写操作如下:

(1)写操作。先判断队列时否为满,如果队列未满,则先写数据,写完数据后再修改write_index。

(2)读操作。先判断队列是否为空,如果队列不为空,则先读数据,读完再修改read_index。

  1. 多生产者单消费者
  • 多生产者和单消费者场景中,由于多个生产者都会修改write_index,所以在不加锁的情况下必须使用原子操作

5.3.2 源码

无锁队列源码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdbool.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/time.h>
#include <sys/mman.h>
 
#define SHM_NAME_LEN 128
#define MIN(a, b) ((a) > (b) ? (b) : (a))
#define IS_POT(x) ((x) && !((x) & ((x)-1)))
#define MEMORY_BARRIER __sync_synchronize()
 
template <class T>
class LockFreeQueue
{
protected:
    typedef struct
    {
        int m_lock;
        inline void spinlock_init()
        {
            m_lock = 0;
        }
 
        inline void spinlock_lock()
        {
            while(!__sync_bool_compare_and_swap(&m_lock, 0, 1)) {}
        }
 
        inline void spinlock_unlock()
        {
            __sync_lock_release(&m_lock);
        }
    } spinlock_t;
 
public:
    // size:队列大小
    // name:共享内存key的路径名称,默认为NULL,使用数组作为底层缓冲区。
    LockFreeQueue(unsigned int size, const char* name = NULL)
    {
        memset(shm_name, 0, sizeof(shm_name));
        createQueue(name, size);
    }
 
    ~LockFreeQueue()
    {
        if(shm_name[0] == 0)
        {
            delete [] m_buffer;
            m_buffer = NULL;
        }
        else
        {
            if (munmap(m_buffer, m_size * sizeof(T)) == -1) {
                perror("munmap");
            }
            if (shm_unlink(shm_name) == -1) {
                perror("shm_unlink");
            }
        }
    }
 
    bool isFull()const
    {
#ifdef USE_POT
        return m_head == (m_tail + 1) & (m_size - 1);
#else
        return m_head == (m_tail + 1) % m_size;
#endif
    }
 
    bool isEmpty()const
    {
        return m_head == m_tail;
    }
 
    unsigned int front()const
    {
        return m_head;
    }
 
    unsigned int tail()const
    {
        return m_tail;
    }
 
    bool push(const T& value)
    {
#ifdef USE_LOCK
        m_spinLock.spinlock_lock();
#endif
        if(isFull())
        {
#ifdef USE_LOCK
            m_spinLock.spinlock_unlock();
#endif
            return false;
        }
        memcpy(m_buffer + m_tail, &value, sizeof(T));
#ifdef USE_MB
        MEMORY_BARRIER;
#endif
 
#ifdef USE_POT
        m_tail = (m_tail + 1) & (m_size - 1);
#else
        m_tail = (m_tail + 1) % m_size;
#endif
 
#ifdef USE_LOCK
        m_spinLock.spinlock_unlock();
#endif
        return true;
    }
 
    bool pop(T& value)
    {
#ifdef USE_LOCK
        m_spinLock.spinlock_lock();
#endif
        if (isEmpty())
        {
#ifdef USE_LOCK
            m_spinLock.spinlock_unlock();
#endif
            return false;
        }
        memcpy(&value, m_buffer + m_head, sizeof(T));
#ifdef USE_MB
        MEMORY_BARRIER;
#endif
 
#ifdef USE_POT
        m_head = (m_head + 1) & (m_size - 1);
#else
        m_head = (m_head + 1) % m_size;
#endif
 
#ifdef USE_LOCK
        m_spinLock.spinlock_unlock();
#endif
        return true;
    }
 
protected:
    virtual void createQueue(const char* name, unsigned int size)
    {
#ifdef USE_POT
        if (!IS_POT(size))
        {
            size = roundup_pow_of_two(size);
        }
#endif
        m_size = size;
        m_head = m_tail = 0;
        if(name == NULL)
        {
            m_buffer = new T[m_size];
        }
        else
        {
            int shm_fd = shm_open(name, O_CREAT | O_RDWR, 0666);
            if (shm_fd < 0)
            {
                perror("shm_open");
            }
 
            if (ftruncate(shm_fd, m_size * sizeof(T)) < 0)
            {
                perror("ftruncate");
                close(shm_fd);
            }
 
            void *addr = mmap(0, m_size * sizeof(T), PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
            if (addr == MAP_FAILED)
            {
                perror("mmap");
                close(shm_fd);
            }
            if (close(shm_fd) == -1)
            {
                perror("close");
                exit(1);
            }
 
            m_buffer = static_cast<T*>(addr);
            memcpy(shm_name, name, SHM_NAME_LEN - 1);
        }
#ifdef USE_LOCK
    spinlock_init(m_lock);
#endif
    }
    inline unsigned int roundup_pow_of_two(size_t size)
    {
        size |= size >> 1;
        size |= size >> 2;
        size |= size >> 4;
        size |= size >> 8;
        size |= size >> 16;
        size |= size >> 32;
        return size + 1;
    }
protected:
    char shm_name[SHM_NAME_LEN];
    volatile unsigned int m_head;
    volatile unsigned int m_tail;
    unsigned int m_size;
#ifdef USE_LOCK
    spinlock_t m_spinLock;
#endif
    T* m_buffer;
};

#define USE_LOCK

开启spinlock锁,多生产者多消费者场景

#define USE_MB

开启Memory Barrier

#define USE_POT

开启队列大小的2的幂对齐

测试代码:

#include "LockFreeQueue.hpp"
#include <thread>
 
//#define USE_LOCK
 
class Test
{
public:
   Test(int id = 0, int value = 0)
   {
        this->id = id;
        this->value = value;
        sprintf(data, "id = %d, value = %d\n", this->id, this->value);
   }
 
   void display()
   {
      printf("%s", data);
   }
private:
   int id;
   int value;
   char data[128];
};
 
double getdetlatimeofday(struct timeval *begin, struct timeval *end)
{
    return (end->tv_sec + end->tv_usec * 1.0 / 1000000) -
           (begin->tv_sec + begin->tv_usec * 1.0 / 1000000);
}
 
LockFreeQueue<Test> queue(1 << 10, "/shm");
 
#define N ((1 << 20))
 
void produce()
{
    struct timeval begin, end;
    gettimeofday(&begin, NULL);
    unsigned int i = 0;
    while(i < N)
    {
        if(queue.push(Test(i >> 10, i)))
            i++;
    }
    gettimeofday(&end, NULL);
    double tm = getdetlatimeofday(&begin, &end);
    printf("producer tid=%lu %f MB/s %f msg/s elapsed= %f size= %u\n", pthread_self(), N * sizeof(Test) * 1.0 / (tm * 1024 * 1024), N * 1.0 / tm, tm, i);
}
 
void consume()
{
    Test test;
    struct timeval begin, end;
    gettimeofday(&begin, NULL);
    unsigned int i = 0;
    while(i < N)
    {
        if(queue.pop(test))
        {
           //test.display();
           i++;
        }
    }
    gettimeofday(&end, NULL);
    double tm = getdetlatimeofday(&begin, &end);
    printf("consumer tid=%lu %f MB/s %f msg/s elapsed= %f size= %u\n", pthread_self(), N * sizeof(Test) * 1.0 / (tm * 1024 * 1024), N * 1.0 / tm, tm, i);
}
 
int main(int argc, char const *argv[])
{
    std::thread producer1(produce);
    //std::thread producer2(produce);
    std::thread consumer(consume);
    producer1.join();
    //producer2.join();
    consumer.join();
 
    return 0;
}
  • 21
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Ant Design Vue 是一个基于 Vue.js 的 UI 组件库,而 vuedraggable 是一个 Vue.js 的拖放组件。两者结合使用,可以实现拖拽排序的功能。 首先,需要在项目中安装 Ant Design Vue 和 vuedraggable: ``` npm install ant-design-vue vuedraggable --save ``` 接着,在需要使用拖拽排序功能的组件中,引入 vuedraggable 组件: ```html <template> <div> <draggable v-model="list" :options="{handle:'.drag-handle'}"> <div v-for="(item, index) in list" :key="item.id"> <span class="drag-handle">☰</span> {{ item.name }} </div> </draggable> </div> </template> <script> import draggable from 'vuedraggable'; export default { components: { draggable, }, data() { return { list: [ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }, { id: 3, name: 'Item 3' }, { id: 4, name: 'Item 4' }, { id: 5, name: 'Item 5' }, ], }; }, }; </script> ``` 在上述示例中,我们使用了 v-model 绑定了一个数组 `list`,这个数组里面包含了需要排序的元素。然后,在 `draggable` 组件中,我们使用了 `v-for` 循环渲染了每个元素,并且给每个元素添加了一个拖拽的句柄(也就是 `drag-handle` 类的元素)。 最后,我们还需要在 `options` 属性中传入一个选项对象,这个对象包含了一个 `handle` 属性,它指定了拖拽句柄的 CSS 选择器。 这样,我们就可以通过拖拽句柄来实现拖拽排序了。当用户拖动一个元素时,它会被移动到新的位置上,并且 `list` 数组中对应的元素顺序也会发生改变。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值