ArrayList源码阅读

文章目录

  • 简介
  • 例子
  • 继承结构概览
  • 代码分析
    • 成员变量
    • 方法
      • 迭代器
      • 子列表
  • 总结
  • 参考链接

本人的源码阅读主要聚焦于类的使用场景,一般只在java层面进行分析,没有深入到一些native方法的实现。并且由于知识储备不完整,很可能出现疏漏甚至是谬误,欢迎指出共同学习

本文基于corretto-17.0.9源码,参考本文时请打开相应的源码对照,否则你会不知道我在说什么

简介

ArrayList实现了List接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入null元素,底层通过数组实现。除该类未实现同步外,其余跟Vector大致相同。每个ArrayList都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量。当向容器中添加元素时,如果容量不足,容器会自动增大底层数组的大小。

例子

ArrayList的API使用起来还是比较简单的,例子的话简单看看就行。

ArrayList<String> sites = new ArrayList<String>();
sites.add("Google");
sites.add("Runoob");
sites.add("Taobao");
sites.add("Weibo");
System.out.println(sites); // 输出[Google, Runoob, Taobao, Weibo]
System.out.println(sites.get(1));  // 访问第二个元素,输出Runoob
sites.set(2, "Wiki"); // 修改第三个元素为Wiki
sites.remove(3); // 删除第四个元素
System.out.println(sites.size()); // 得到元素个数
// 使用for-each遍历
for (String i : sites) {
  System.out.println(i);
}

继承结构概览

当开始分析一个类之前,肯定得知道它继承了什么类或者接口,因为在该类中可能会调用父类的方法,因此有必要先去了解这个类的继承体系。ArrayList继承于AbstractList,AbstractList继承于AbstractCollection并实现List,AbstractCollection又实现Collection。用图表示如下,隐藏了不太算JCF相关的接口,比如Cloneable,Object这些,只保留集合相关的类和接口:

image-20231229175147152

其中的AbstractCollection用于提供一个实现了Collection的抽象类,提供了一些在实现Collection接口时某些方法的通用实现,类似一个骨架,重复性工作都给你做好了,想要实现Collection的类可以首先考虑继承AbstractCollection。

类似地,AbstractList是List的骨架类,提供了一些在实现List接口时某些方法的通用实现。

不熟悉JCF的基础接口和类的话,可以先看这篇:JCF相关基础类接口/抽象类源码阅读

整明白JCF继承结构之后,看一下完整的继承结构:

image-20240107142510975

也就多了三个标记接口:RandomAccess、Cloneabl、Serializable,标记接口指的是没有任何方法的空接口,比如RandomAccess只是用来给使用ArrayList的用户说明这个类可以高效地实现随机访问。这些标记接口不影响我们分析类,想进一步了解可以参考我的JCF基础的博客,这里不再赘述。

代码分析

成员变量

ArrayList的成员变量很少,只有底层数组和size:

transient Object[] elementData;
private int size;

因此ArrayList其实就是一个对原始数组的封装,并且内部通过强制类型转换实现Object到泛型的转换。

因为是用数组实现的,数组本身的大小是固定的,当大小不足容下更多的元素,只能重新创建更大的新数组,而且为了避免频繁扩容带来的开销,扩容后的大小总是会 >= 实际元素的个数。因此引出两个概念:capacity和size,前者是数组的大小,即elementData.length,后者则是ArrayList实际存储了的元素个数,存于size变量中。

另外还提前声明了两个静态空数组,作用如下:

// capacity为0时直接用它,避免重复创建空数组
private static final Object[] EMPTY_ELEMENTDATA = {};
// 用于无参构造函数构造时,capacity为0,之后第一次扩容至少要到DEFAULT_CAPACITY。与EMPTY_ELEMENTDATA区分开来
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

方法

方法这块看起来讲得比较乱,其实顺序是按照源码从上到下的顺序讲的,并且函数之间的调用我会先讲被调用的函数,或者串讲。

ArrayList有三种构造函数:

// 空列表,capacity和size都为0,第一次扩容至少要扩容到DEFAULT_CAPACITY
public ArrayList();

// capacity为initalCapacity,size为0
public ArrayList(int initialCapacity);

// capacity和size都为c.length,保存的元素与c中的相同
public ArrayList(Collection<? extends E> c);

比较直观,不列实现代码了。下面看下其他方法,其中一些实现简单的方法就不细讲

// 将capacity缩小到刚好能容纳所有元素,具体是通过elementData = Arrays.copyOf(elementData, size)
public void trimToSize();

// 确保数组大小至少为minCapacity
public void ensureCapacity(int minCapacity) {
  // 第二个条件:当elementData为DEFAULTCAPACITY_EMPTY_ELEMENTDATA,至少会扩容到DEFAULT_CAPACITY,因此minCapacity小于DEFAULT_CAPACITY的话,直接忽略本次扩容
  if (minCapacity > elementData.length
    && !(elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
         && minCapacity <= DEFAULT_CAPACITY)) {
    modCount++;
    grow(minCapacity);
  }
}

// 将数组扩容到至少minCapacity
private Object[] grow(int minCapacity) {
  int oldCapacity = elementData.length;
  if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
    // 如果数组不是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
    // 扩容为oldCapacity的1.5倍(希望扩容的大小),否则扩容到minCapacity
    int newCapacity = ArraysSupport.newLength(oldCapacity,
      minCapacity - oldCapacity, /* minimum growth */
      oldCapacity >> 1           /* preferred growth */);
    return elementData = Arrays.copyOf(elementData, newCapacity);
  } else {
    // 如果数组是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,至少扩容到DEFAULT_CAPACITY
    return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
  }
}

这里grow(minCapacity)方法中,ArraysSupport.newLength是个什么的呢?简单来说这个函数是基于当前数组长度、希望增长的长度、最少增长的长度,来计算新的数组长度的。当然,里面还有更加细致的考量,了解一下即可:

// 基于当前数组的长度,计算新的长度
// minGrowth是最少要增长的长度
// prefGrowth是希望增长的长度
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
  int prefLength = oldLength + Math.max(minGrowth, prefGrowth); // might overflow
  if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {
    // 如果希望增长的长度合适,则直接返回
    return prefLength;
  } else {
		// 希望增长的长度不适合,继续计算
    return hugeLength(oldLength, minGrowth);
  }
}

private static int hugeLength(int oldLength, int minGrowth) {
  int minLength = oldLength + minGrowth;
  if (minLength < 0) {
    // minLength > Integer.MAX
    throw new OutOfMemoryError(
        "Required array length " + oldLength + " + " + minGrowth + " is too large");
  } else if (minLength <= SOFT_MAX_ARRAY_LENGTH) {
    // minLength <= SOFT_MAX_ARRAY_LENGTH,说明prefGrowth > minGrowth但是prefGrowth会导致prefLength > SOFT_MAX_ARRAY_LENGTH,那么让结果尽量接近prefGrowth,直接返回SOFT_MAX_ARRAY_LENGTH
    return SOFT_MAX_ARRAY_LENGTH;
  } else {
    // minLength > SOFT_MAX_ARRAY_LENGTH 并且 minLength < Integer.MAX,倒也可以返回minLength。SOFT_MAX_ARRAY_LENGTH实际上是照顾JVM的实现,因为有的JVM对最大数组大小的限制在Integer.MAX附近,而SOFT_MAX_ARRAY_LENGTH可以保证不会超出大部分JVM最大数组的限制
    return minLength;
  }
}

继续看其他的方法:

// ArrayList继承了Cloneable,支持克隆一个新的ArrayList。底层数组与克隆的底层数组是两个不同的实例,但里面的元素都是一样的
public Object clone() {
  try {
    // 获取浅拷贝
    ArrayList<?> v = (ArrayList<?>) super.clone();
    // 创建新数组
    v.elementData = Arrays.copyOf(elementData, size);
    // 新的ArrayList,modCount(结构性修改次数)自然是0
    v.modCount = 0;
    return v;
  } catch (CloneNotSupportedException e) {
    // this shouldn't happen, since we are Cloneable
    throw new InternalError(e);
  }
}

// 将元素插入index为下标的位置,原来[index, size)的元素都往后移动
public void add(int index, E element) {
  rangeCheckForAdd(index);
  modCount++;
  final int s;
  Object[] elementData;
  if ((s = size) == (elementData = this.elementData).length)
    elementData = grow();
  // 将从index开始到数组末尾的元素,整体复制到index+1开始的位置
  // remove(index)的实现也是用这个System.arraycopy,只不过是index+1的位置复制到index而已
  System.arraycopy(elementData, index,
                   elementData, index + 1,
                   s - index);
  elementData[index] = element;
  size = s + 1;
}

// 当两个List拥有相同size并且每个元素都equals时返回true
// 如果o不是ArrayList(但仍是List)则使用迭代器遍历,如果是ArrayList的话,使用fori遍历
public boolean equals(Object o) {
  if (o == this) {
    return true;
  }

  if (!(o instanceof List)) {
    return false;
  }

  final int expectedModCount = modCount;
  boolean equal = (o.getClass() == ArrayList.class)
      ? equalsArrayList((ArrayList<?>) o)
      : equalsRange((List<?>) o, 0, size);

  checkForComodification(expectedModCount);
  return equal;
}

// clear名为删除所有元素,在ArrayList中实现为将数组每个引用置空,使得将来可以复用数组
public void clear() {
  modCount++;
  final Object[] es = elementData;
  for (int to = size, i = size = 0; i < to; i++)
    es[i] = null;
}

还有几个可能稍微难理解一点的方法:

// 相当于删除在[lo, hi)间的元素
private void shiftTailOverGap(Object[] es, int lo, int hi) {
  System.arraycopy(es, hi, es, lo, size - hi);
  for (int to = size, i = (size -= hi - lo); i < to; i++)
    es[i] = null;
}

// 当complement=false,删除在[from, end)间,且存在于c中的元素
// 当complement=true,删除在[from, end)间,且不存在于c中的元素
boolean batchRemove(
  Collection<?> c, boolean complement,
  final int from, final int end) {
  Objects.requireNonNull(c);
  final Object[] es = elementData;
  int r;
  // 确定从哪开始删除:这个for循环完了之后,r保存第一个将被删除元素的下标
  for (r = from;; r++) {
    if (r == end)
      return false;
    if (c.contains(es[r]) != complement)
      break;
  }
  // w保存将要保留的元素的下标
  // 这是实现删除数组中某些元素的时候常用的双指针算法,目的是保持数组元素连续:w和r分别为两个指针,r负责遍历(r无条件自增),当r指向的元素符合条件的时候存到w的位置,w自增(不符合条件的话w保持当前值)
  int w = r++;
  try {
    for (Object e; r < end; r++)
      if (c.contains(e = es[r]) == complement) // 遍历到要删除的元素
        es[w++] = e;
  } catch (Throwable ex) {
    // 如果contains抛异常,为了避免数组出现空洞做的善后处理
    // 放弃遍历[r, end)这堆没遍历完的元素,直接把他们前移到w指向的位置
    System.arraycopy(es, r, es, w, end - r);
    w += end - r;
    throw ex;
  } finally {
    modCount += end - w;
    // 处理完元素之后[w, end)这段会出现空洞,把[end, size)前移进来
    shiftTailOverGap(es, w, end);
  }
  return true;
}

// 删除[i, end)区间中所有满足filter的元素
// 与batchRemove大同小异,不同之处只是在于条件通过filter.test判断
boolean removeIf(Predicate<? super E> filter, int i, final int end) {
  Objects.requireNonNull(filter);
  int expectedModCount = modCount;
  final Object[] es = elementData;
  // 确定从哪开始删除:这个for循环完了之后,i保存第一个将被删除元素的下标
  for (; i < end && !filter.test(elementAt(es, i)); i++)
    ;
  if (i < end) {
    // 与batchRemove的不同:先标记所有符合条件(将被删除)的元素,然后才删除
    // 这样做的原因在于:filter.test传入的是当前遍历到的列表元素,但其可能不仅依赖于传入的元素来决定返回值为true还是false,而是依赖于整个列表(也就是可能会依赖于其他的元素)。因此在要保证在filter遍历的过程中列表是可重入读的,即不能遇到filter.test返回true就立刻删除,这样对于下一次filter.test来说列表已经发生了改变。而是用deathRow先标记将要删除的元素,遍历完之后再一次性删除。
    final int beg = i;
    final long[] deathRow = nBits(end - beg);
    deathRow[0] = 1L;
    for (i = beg + 1; i < end; i++)
      if (filter.test(elementAt(es, i)))
        setBit(deathRow, i - beg);
    if (modCount != expectedModCount)
      throw new ConcurrentModificationException();
    modCount++;
    int w = beg;
    // 双指针w和i
    for (i = beg; i < end; i++)
      if (isClear(deathRow, i - beg))
        es[w++] = es[i];
    shiftTailOverGap(es, w, end);
    return true;
  } else {
    if (modCount != expectedModCount)
      throw new ConcurrentModificationException();
    return false;
  }
}

第一个函数shiftTailOverGap不用多说

第二个函数batchRemove简单总结就是用来移除数组中包含于c/不包含于c中的元素,并且函数退出之后,数组不能出现空洞,即数组留下来的元素得保持连续,不然的话之后这个数组还怎么使用hhhhh。

第三个函数removeIf与batchRemove是一样的思想,只不过移除的是“符合条件的元素”,用途更为广泛一点。batchRemove相当于是其的一个特例,条件为“是否包含于/不包含于c”。然后还得保证predict(filter)的可重入性,也就是该方法对于调用者有一个规范/假设:对每个元素进行predict的时,列表结构不能发生改变,更具体的解释可以看注释。

至此,基本的方法介绍得差不多了,都比较简单,下面来看一下迭代器和子列表相关的东西。ArrayList没有使用AbstractList提供的默认迭代器类和子列表类,而是自己重新实现了一套,至于为什么,我想是为了直接操纵底层数组提高运行效率(注释也说了An optimized version of AbstractList.Itr)。

先看迭代器:

迭代器

private class Itr implements Iterator<E> {
  int cursor;       // index of next element to return
  int lastRet = -1; // index of last element returned; -1 if no such
  int expectedModCount = modCount;
  
  // ...
}

private class ListItr extends Itr implements ListIterator<E> {
  // ...
}

还是熟悉的配方。。。参考我JCF博客关于AbstractList.Itr和AbstractList.ListItr的分析就行了,大同小异。不同之处只是ArrayList直接访问底层数组,而AbstractList借助get方法访问。

迭代器是fail-fast的,意思是会在运行过程中检测结构性修改,如果有的话直接抛出ConcurrentModificationException异常,而不是继续迭代遍历。这一点其实在AbstractList.Itr也是一样的。

但注意ArrayList不是并发安全的,因此用户不能完全依赖于fail-test来判断有没有发生结构性修改,可能在某个时间点检查是正常的,在这个时间点之后发生了结构性修改无法再检测出来,fail-fast只是尽最大努力去发现结构性修改。总结下来即:抛异常则一定发生了结构性修改,但没抛异常不一定意味着没发生结构性修改,因此这个特性只能用来debug,或者说这个是一个heuristic的功能(启发式的,意味着不一定准确)。

子列表

private static class SubList<E> extends AbstractList<E> implements RandomAccess {
  private final ArrayList<E> root;
  private final SubList<E> parent;
  private final int offset;
  private int size;
}

成员变量还是熟悉的配方,里面的方法都与AbstractList.SubList的大同小异,基本上类似根据菜谱做菜(先放油、再放菜、放盐、放葱)那样,有的方法看着有点长,其实就是步骤比较多:先访问元素、修改指针、检查并发访问…就不再介绍了。

总结

整体来说ArrayList没什么难点,掌握核心思想,即:ArrayList就是对数组进行封装的类,并通过扩容机制(每次扩容1.5倍)实现了动态修改大小的特性。其中有的方法看起来很长,其实是维护变量罢了,理解核心思想的话,并不难分析。还有几个方法比如batchRemove、removeIf可能有些让人疑惑的点,比如双指针算法、标记删除法,读懂就好了。

最后ArrayList不是并发安全的,进一步来说,里面有的方法可能会抛出ConcurrentModificationException,但是不抛出这个异常的情况下,不代表着没有“异常”。

参考链接

「博客园」JCF相关基础类接口/抽象类源码阅读

「博客园」J源码中transient的用途

「Java全栈知识体系」Collection - ArrayList 源码解析

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

[机缘参悟-125] :实修 - “心性、自性”与“知识、技能”的区别,学习、修、悟的区别?

目录 一、“知识、技能” 1.1 什么是知识技能 1.2 知识、技能的位置 1.3 知识、技能的学习方法 二、"明心见性" 2.1 什么是"明心见性" 2.2 "明心见性"解读 2.2.1 何其自性&#xff0c;本自清净&#xff1b; 2.2.2 何其自性&#xff0c;…

将.NET应用转换成Window服务

写在前面 本文介绍了将.NET8.0应用程序转换成Windows服务。 需要在NuGet中获取并安装&#xff1a;Microsoft.Extensions.Hosting.WindowsServices 包 代码实现 using System.Runtime.InteropServices; using WorkerService1;public class Program {public static void Main…

【机器学习300问】6、什么是机器学习中的特征量?

一、首先我们看三个例子 例一&#xff1a;在辨别水果的任务中&#xff0c;人类一般会通过外观、味道、颜色等方面信息来进行区分。而机器学习则通过水果的颜色、重量、气味成分的量等被称之为“特征量”的数值来区分。 例二&#xff1a;在手写数字识别任务中&#xff0c;人类…

【Golang】二进制字符串转换为数字

在本文中&#xff0c;我们将探讨如何使用 Go 语言将十六进制字符串转换为二进制字符串&#xff0c;将不定长整型补码字符串转换为数字&#xff0c;以及如何将 IEEE754 标准的单精度&#xff08;32位&#xff09;和双精度&#xff08;64位&#xff09;浮点数字符串转换为数字。最…

LaTeX中的框以及框中的子图

目录 文章目录 目录框&#xff08;盒子&#xff09;\fboxframed包framed环境leftbar环境 mdframed包fcolorbox命令tcolorbox包adjustbox包调整盒子的宽度和高度旋转盒子 框中的子图问题一&#xff1a;框中插入图片问题二&#xff1a;给框中图片加上图名、编号caption包 问题三&…

动态规划学习笔记

背景 一般形式是求最值&#xff0c;核心是穷举。 首先&#xff0c;虽然动态规划的核心思想就是穷举求最值&#xff0c;但是问题可以千变万化&#xff0c;穷举所有可行解其实并不是一件容易的事&#xff0c;需要你熟练掌握递归思维&#xff0c;只有列出正确的「状态转移方程」…

【Python】新鲜出炉的海洋捕食者算法Python版本

2020年发表的海洋捕食者算法《Marine Predators Algorithm: A nature-inspired metaheuristic》。 作者只在原论文中给出了MATLAB代码&#xff0c;网上也没有Python版本&#xff0c;我自己用Python重写了MATLAB代码。 """2020海洋捕食者算法 """…

【自控实验】4. 数字仿真实验

本科课程实验报告&#xff0c;有太多公式和图片了&#xff0c;干脆直接转成图片了 仅分享和记录&#xff0c;不保证全对 使用matlab中的simulink进行仿真 实验内容 线性连续控制系统的数字仿真 根据开环传递函数G(S)的不同&#xff0c;完成两个线性连续控制系统的仿真。 …

C#上位机与欧姆龙PLC的通信12----【再爆肝】上位机应用开发(WPF版)

1、先上图 继上节完成winform版的应用后&#xff0c;今天再爆肝wpf版的&#xff0c;看看看。 可以看到&#xff0c;wpf的确实还是漂亮很多&#xff0c;现在人都喜欢漂亮的&#xff0c;颜值高的&#xff0c;现在是看脸时代&#xff0c;作为软件来说&#xff0c;是交给用户使用的…

【目标检测】YOLOv5算法实现(七):模型训练

本系列文章记录本人硕士阶段YOLO系列目标检测算法自学及其代码实现的过程。其中算法具体实现借鉴于ultralytics YOLO源码Github&#xff0c;删减了源码中部分内容&#xff0c;满足个人科研需求。   本系列文章主要以YOLOv5为例完成算法的实现&#xff0c;后续修改、增加相关模…

vue3hooks的使用

在 Vue 3 中&#xff0c;hooks 是用于封装组件逻辑的方法&#xff0c;类似于 Vue 2 中的 mixins。 使用 Hooks 可以提高代码的可维护性、可读性、可复用性和可测试性&#xff0c;降低代码之间的耦合度&#xff0c;使得组件的状态更加可控和可预测。 要使用 hooks&#xff0c;…

【VRTK】【Unity】【游戏开发】更多技巧

课程配套学习项目源码资源下载 https://download.csdn.net/download/weixin_41697242/88485426?spm=1001.2014.3001.5503 【概述】 本篇将较为零散但常用的VRTK开发技巧集合在一起,主要内容: 创建物理手震动反馈高亮互动对象【创建物理手】 非物理手状态下,你的手会直接…

MATLAB - 四旋翼飞行器动力学方程

系列文章目录 前言 本例演示了如何使用 Symbolic Math Toolbox™&#xff08;符号数学工具箱&#xff09;推导四旋翼飞行器的连续时间非线性模型。具体来说&#xff0c;本例讨论了 getQuadrotorDynamicsAndJacobian 脚本&#xff0c;该脚本可生成四旋翼状态函数及其雅各布函数…

C++|44.智能指针

文章目录 智能指针unique_ptr特点一——无法进行复制 shared_ptr特点一——可复制特点二——计数器&#xff08;用于确定删除的时机&#xff09; 其他 智能指针 通常的指针是需要特殊地去申请对应的空间&#xff0c;并在不使用的时候还需要人工去销毁。 而智能指针相对普通的指…

ubuntu20.04网络问题以及解决方案

1.网络图标消失&#xff0c;wired消失&#xff0c;ens33消失 参考&#xff1a;https://blog.51cto.com/u_204222/2465609 https://blog.csdn.net/qq_42265170/article/details/123640669 原始是在虚拟机中切换网络连接方式&#xff08;桥接和NAT&#xff09;&#xff0c; 解决…

Java-网络爬虫(三)

文章目录 前言一、爬虫的分类二、跳转页面的爬取三、网页去重四、综合案例1. 案例三 上篇&#xff1a;Java-网络爬虫(二) 前言 上篇文章介绍了 webMagic&#xff0c;通过一个简单的入门案例&#xff0c;对 webMagic 的核心对象和四大组件都做了简要的说明&#xff0c;以下内容…

LeetCode---121双周赛---数位dp

题目列表 2996. 大于等于顺序前缀和的最小缺失整数 2997. 使数组异或和等于 K 的最少操作次数 2998. 使 X 和 Y 相等的最少操作次数 2999. 统计强大整数的数目 一、大于等于顺序前缀和的最小缺失整数 简单的模拟题&#xff0c;只要按照题目的要求去写代码即可&#xff0c;…

高级分布式系统-第6讲 分布式系统的容错性--进程的容错

分布式系统的容错原则既适用于硬件&#xff0c; 也适用于软件。 两者的主要区别在于硬件部件的同类复制相对容易&#xff0c; 而软件组件在运行中的同类复制&#xff08; 进程复制&#xff09; 涉及到更为复杂的分布式操作系统的容错机制。 以下是建立进程失效容错机制的一些基…

腾讯云添加SSL证书

一、进入腾讯云SSL证书&#xff1a; ssl证书控制台地址 选择“我的证书”&#xff0c;点击"申请免费证书" 2、填写域名和邮箱&#xff0c;点击“提交申请” 在此页面中会出现主机记录和记录值。 2、进入云解析 DNS&#xff1a;云解析DNS地址 进入我的解析-记录…

css3基础语法与盒模型

css3基础语法与盒模型 前言CSS3基础入门css3的书写位置内嵌式外链式导入式&#xff08;工作中几乎不用&#xff09;行内式 css3基本语法css3选择器标签选择器id选择器class类名原子类复合选择器伪类元素关系选择器序号选择器属性选择器css3新增伪类![在这里插入图片描述](https…