Netty核心源码剖析

news2024/11/15 7:04:17

Netty 线程模型

image.png

Netty高并发高性能架构设计精髓

  1. 主从Reactor线程模型
  2. NIO多路复用非阻塞
  3. 无锁串行化设计思想
  4. 支持高性能序列化协议
  5. 零拷贝(直接内存的使用)
  6. ByteBuf内存池设计
  7. 灵活的TCP参数配置能力
  8. 并发优化

无锁串行化设计思想

在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会导致性能的下降。为了尽可能的避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。NIO的多路复用就是一种无锁串行化的设计思想(理解下Redis和Netty的线程模型),为了尽可能提升性能,Netty采用了串行无锁化设计,在IO线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比“一个队列-多个工作线程”模型性能更优。
Netty的NioEventLoop读取到消息之后,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,某些情况下这部分内存也会被频繁地使用,而且也可能导致OutOfMemoryError异常出现。Java里用DirectByteBuffer可以分配一块直接内存(堆外内存),元空间对应的内存也叫作直接内存,它们对应的都是机器的物理内存。
image.png
测试代码:

package com.firechou.test;

import java.nio.ByteBuffer;

/**
 * 直接内存与堆内存的区别
 */
public class DirectMemoryTest {

    public static void heapAccess() {
        long startTime = System.currentTimeMillis();
        //分配堆内存
        ByteBuffer buffer = ByteBuffer.allocate(1000);
        for (int i = 0; i < 100000; i++) {
            for (int j = 0; j < 200; j++) {
                buffer.putInt(j);
            }
            buffer.flip();
            for (int j = 0; j < 200; j++) {
                buffer.getInt();
            }
            buffer.clear();
        }
        long endTime = System.currentTimeMillis();
        System.out.println("堆内存访问:" + (endTime - startTime) + "ms");
    }

    public static void directAccess() {
        long startTime = System.currentTimeMillis();
        //分配直接内存
        ByteBuffer buffer = ByteBuffer.allocateDirect(1000);
        for (int i = 0; i < 100000; i++) {
            for (int j = 0; j < 200; j++) {
                buffer.putInt(j);
            }
            buffer.flip();
            for (int j = 0; j < 200; j++) {
                buffer.getInt();
            }
            buffer.clear();
        }
        long endTime = System.currentTimeMillis();
        System.out.println("直接内存访问:" + (endTime - startTime) + "ms");
    }

    public static void heapAllocate() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            ByteBuffer.allocate(100);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("堆内存申请:" + (endTime - startTime) + "ms");
    }

    public static void directAllocate() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            ByteBuffer.allocateDirect(100);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("直接内存申请:" + (endTime - startTime) + "ms");
    }

    public static void main(String args[]) {
        for (int i = 0; i < 10; i++) {
            heapAccess();
            directAccess();
        }

        System.out.println();

        for (int i = 0; i < 10; i++) {
            heapAllocate();
            directAllocate();
        }
    }
}

// 运行结果
堆内存访问:70ms
直接内存访问:44ms
堆内存访问:40ms
直接内存访问:28ms
堆内存访问:67ms
直接内存访问:55ms
堆内存访问:57ms
直接内存访问:41ms
堆内存访问:59ms
直接内存访问:36ms
堆内存访问:48ms
直接内存访问:32ms
堆内存访问:44ms
直接内存访问:26ms
堆内存访问:38ms
直接内存访问:25ms
堆内存访问:44ms
直接内存访问:29ms
堆内存访问:45ms
直接内存访问:26ms

堆内存申请:14ms
直接内存申请:51ms
堆内存申请:13ms
直接内存申请:40ms
堆内存申请:75ms
直接内存申请:55ms
堆内存申请:2ms
直接内存申请:26ms
堆内存申请:2ms
直接内存申请:83ms
堆内存申请:1ms
直接内存申请:34ms
堆内存申请:5ms
直接内存申请:34ms
堆内存申请:2ms
直接内存申请:38ms
堆内存申请:7ms
直接内存申请:37ms
堆内存申请:7ms
直接内存申请:35ms

从程序运行结果看出直接内存申请较慢,但访问效率高。在java虚拟机实现上,本地IO一般会直接操作直接内存(直接内存=>系统调用=>硬盘/网卡),而非直接内存则需要二次拷贝(堆内存=>直接内存=>系统调用=>硬盘/网卡)。

直接内存分配源码分析:

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}


DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    //判断是否有足够的直接内存空间分配,可通过-XX:MaxDirectMemorySize=<size>参数指定直接内存最大可分配空间,如果不指定默认为最大堆内存大小,
    //在分配直接内存时如果发现空间不够会显示调用System.gc()触发一次full gc回收掉一部分无用的直接内存的引用对象,同时直接内存也会被释放掉
    //如果释放完分配空间还是不够会抛出异常java.lang.OutOfMemoryError
   Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 调用unsafe本地方法分配直接内存
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        // 分配失败,释放内存
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    
    // 使用Cleaner机制注册内存回收处理函数,当直接内存引用对象被GC清理掉时,
    // 会提前调用这里注册的释放直接内存的Deallocator线程对象的run方法
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}


// 申请一块本地内存。内存空间是未初始化的,其内容是无法预期的。
// 使用freeMemory释放内存,使用reallocateMemory修改内存大小
public native long allocateMemory(long bytes);

// openjdk8/hotspot/src/share/vm/prims/unsafe.cpp
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory(JNIEnv *env, jobject unsafe, jlong size))
  UnsafeWrapper("Unsafe_AllocateMemory");
  size_t sz = (size_t)size;
  if (sz != (julong)size || size < 0) {
    THROW_0(vmSymbols::java_lang_IllegalArgumentException());
  }
  if (sz == 0) {
    return 0;
  }
  sz = round_to(sz, HeapWordSize);
  // 调用os::malloc申请内存,内部使用malloc这个C标准库的函数申请内存
  void* x = os::malloc(sz, mtInternal);
  if (x == NULL) {
    THROW_0(vmSymbols::java_lang_OutOfMemoryError());
  }
  //Copy::fill_to_words((HeapWord*)x, sz / HeapWordSize);
      return addr_to_java(x);
UNSAFE_END

使用直接内存的优缺点:
优点:

  • 不占用堆内存空间,减少了发生GC的可能
  • java虚拟机实现上,本地IO会直接操作直接内存(直接内存=>系统调用=>硬盘/网卡),而非直接内存则需要二次拷贝(堆内存=>直接内存=>系统调用=>硬盘/网卡)

缺点:

  • 初始分配较慢
  • 没有JVM直接帮助管理内存,容易发生内存溢出。为了避免一直没有FULL GC,最终导致直接内存把物理内存耗完。我们可以指定直接内存的最大值,通过-XX:MaxDirectMemorySize来指定,当达到阈值的时候,调用system.gc来进行一次FULL GC,间接把那些没有被使用的直接内存回收掉。

Netty 零拷贝

image.png
Netty的接收和发送ByteBuf采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。
如果使用传统的JVM堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才能写入Socket中。JVM堆内存的数据是不能直接写入Socket中的。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
可以看下netty的读写源码,比如read源码NioByteUnsafe.read()
image.png
image.png
image.png

ByteBuf内存池设计

随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区Buffer(相当于一个内存块),情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty提供了基于ByteBuf内存池的缓冲区重用机制。需要的时候直接从池子里获取ByteBuf使用即可,使用完毕之后就重新放回到池子里去。下面我们一起看下Netty ByteBuf的实现:
image.png
可以看下netty的读写源码里面用到的ByteBuf内存池,比如read源码NioByteUnsafe.read()

灵活的TCP参数配置能力

合理设置TCP参数在某些场景下对于性能的提升可以起到显著的效果,例如接收缓冲区SO_RCVBUF和发送缓冲区SO_SNDBUF。如果设置不当,对性能的影响是非常大的。通常建议值为128K或者256K。
Netty在启动辅助类ChannelOption中可以灵活的配置TCP参数,满足不同的用户场景。
image.png

并发优化

  • volatile的大量、正确使用;
  • CAS和原子类的广泛使用;
  • 线程安全容器的使用;
  • 通过读写锁提升并发性能。

ByteBuf扩容机制

如果我们需要了解ByteBuf的扩容,我们需要先了解ByteBuf中定义的几个成员变量,再从源码的角度来分析扩容。
image.png

minNewCapacity:表示用户需要写入的值大小
threshold:阈值,为Bytebuf内部设定容量的最大值
maxCapacity:Netty最大能接受的容量大小,一般为int的最大值

ByteBuf核心扩容方法:
进入ByteBuf源码中,深入分析其扩容方法,idea源码进入:ByteBuf.writeByte()->AbstractByteBuf->calculateNewCapacity

// io.netty.buffer.AbstractByteBufAllocator#calculateNewCapacity
@Override
public int calculateNewCapacity(int minNewCapacity, int maxCapacity) {
    checkPositiveOrZero(minNewCapacity, "minNewCapacity");
    if (minNewCapacity > maxCapacity) {
        // 比最大的容量还大的话,抛出异常
        throw new IllegalArgumentException(String.format(
                "minNewCapacity: %d (expected: not greater than maxCapacity(%d)",
                minNewCapacity, maxCapacity));
    }
    final int threshold = CALCULATE_THRESHOLD; // 4 MiB page

    if (minNewCapacity == threshold) {
        // 等于,返回阈值
        return threshold;
    }

    // If over threshold, do not double but just increase by threshold.
    if (minNewCapacity > threshold) {
        // 采用步进4MB的方式完成扩容
        int newCapacity = minNewCapacity / threshold * threshold;
        if (newCapacity > maxCapacity - threshold) {
            newCapacity = maxCapacity;
        } else {
            newCapacity += threshold;
        }
        return newCapacity;
    }

    // Not over threshold. Double up to 4 MiB, starting from 64.
    // 采用64为基数,做倍增的方式完成扩容
    int newCapacity = 64;
    while (newCapacity < minNewCapacity) {
        newCapacity <<= 1;
    }

    return Math.min(newCapacity, maxCapacity);
}

  1. 判断目标值与阈值threshold(4MB)的大小关系,等于直接返回阈值
  2. 采用步进4MB的方式完成扩容
  3. 采用64为基数,做倍增的方式完成扩容

总结:
Netty的ByteBuf需要动态扩容来满足需要,扩容过程:默认门限阈值为4MB(这个阈值是一个经验值,不同场景,可能取值不同),当需要的容量等于门限阈值,使用阈值作为新的缓存区容量目标容量,如果大于阈值,采用每次步进4MB的方式进行内存扩张((需要扩容值/4MB)*4MB),扩张后需要和最大内存(maxCapacity)进行比较,大于maxCapacity的话就用maxCapacity,否则使用扩容值目标容量,如果小于阈值,采用倍增的方式,以64(字节)作为基本数值,每次翻倍增长64 -->128 --> 256,直到倍增后的结果大于或等于需要的容量值。

handler的生命周期回调接口调用顺序

/**
 *  在channel的pipeline里如下handler:ch.pipeline().addLast(new LifeCycleInBoundHandler());
 *  handler的生命周期回调接口调用顺序:
 *  handlerAdded -> channelRegistered -> channelActive -> channelRead -> channelReadComplete
 *  -> channelInactive -> channelUnRegistered -> handlerRemoved
 *
 * handlerAdded: 新建立的连接会按照初始化策略,把handler添加到该channel的pipeline里面,也就是channel.pipeline.addLast(new LifeCycleInBoundHandler)执行完成后的回调;
 * channelRegistered: 当该连接分配到具体的worker线程后,该回调会被调用。
 * channelActive:channel的准备工作已经完成,所有的pipeline添加完成,并分配到具体的线上上,说明该channel准备就绪,可以使用了。
 * channelRead:客户端向服务端发来数据,每次都会回调此方法,表示有数据可读;
 * channelReadComplete:服务端每次读完一次完整的数据之后,回调该方法,表示数据读取完毕;
 * channelInactive:当连接断开时,该回调会被调用,说明这时候底层的TCP连接已经被断开了。
 * channelUnRegistered: 对应channelRegistered,当连接关闭后,释放绑定的workder线程;
 * handlerRemoved: 对应handlerAdded,将handler从该channel的pipeline移除后的回调方法。
 */
public class LifeCycleInBoundHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRegistered(ChannelHandlerContext ctx)
            throws Exception {
        System.out.println("channelRegistered: channel注册到NioEventLoop");
        super.channelRegistered(ctx);
    }

    @Override
    public void channelUnregistered(ChannelHandlerContext ctx) 
            throws Exception {
        System.out.println("channelUnregistered: channel取消和NioEventLoop的绑定");
        super.channelUnregistered(ctx);
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) 
            throws Exception {
        System.out.println("channelActive: channel准备就绪");
        super.channelActive(ctx);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) 
            throws Exception {
        System.out.println("channelInactive: channel被关闭");
        super.channelInactive(ctx);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) 
            throws Exception {
        System.out.println("channelRead: channel中有可读的数据" );
        super.channelRead(ctx, msg);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) 
            throws Exception {
        System.out.println("channelReadComplete: channel读数据完成");
        super.channelReadComplete(ctx);
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) 
            throws Exception {
        System.out.println("handlerAdded: handler被添加到channel的pipeline");
        super.handlerAdded(ctx);
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) 
            throws Exception {
        System.out.println("handlerRemoved: handler从channel的pipeline中移除");
        super.handlerRemoved(ctx);
    }
}

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

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

相关文章

网络原理之TCP协议(超详细 干货满满)

文章目录 前言TCP 协议的段格式TCP 协议的相关特性什么叫做可靠传输TCP 采用了哪些主要机制保证了可靠传输和优化传输效率1. 确认应答2. 超时重传3. 连接管理&#xff08;三次握手、四次挥手&#xff09;三次握手&#xff08;建立连接&#xff09;四次挥手&#xff08;断开连接…

【方法】如何给PDF文件添加“打开密码”?

PDF文件可以在线浏览&#xff0c;但如果想要给文件添加“打开密码”&#xff0c;就需要用到软件工具&#xff0c;下面小编分享两种常用的工具&#xff0c;小伙伴们可以根据需要选择。 工具一&#xff1a;PDF编辑器 PDF阅读器一般是没有设置密码的功能模块&#xff0c;PDF编辑器…

全志A523(显示篇一)

全志使用de架构&#xff0c;兼容drm架构 返回目录

全面的‘由于找不到mfc110u.dll,无法继续执行代码’的解决方法分享,3分钟教你快速修复

在我们使用电脑的过程中&#xff0c;有时候可能会遇到某个应用程序启动失败&#xff0c;提示“由于找不到mfc110u.dll,无法继续执行代码”的问题。本文将详细介绍如何针对这类问题进行处理&#xff0c;以及mfc110u.dll文件的相关知识。 一.mfc110u.dll文件盘点 首先&#xff0…

用豆瓣电影和掌桥科研练习网页解析的三种方式——正则、Xpath和bs4

网页解析 豆瓣电影解析方式正则表达式Xpathbs4 翻页 掌桥科研正则表达式Xpathbs4 豆瓣电影 解析方式 先爬取数据&#xff1a; # -- coding: utf-8 --** import requests import json import time import pandas as pdurlhttps://movie.douban.com/top250?start0&filter…

【带头学C++】----- 1.基础知识 ---- 1.21.23.9 位运算符的综合应用

最近做任务&#xff0c;公司项目比较重&#xff0c;赶上1024的活动流量券任务&#xff0c;内容治疗略微有一些杂乱&#xff0c;后期会把专栏目录重新搞一下&#xff0c;内容我是融合了很多课程和书籍包含ai的一些理解&#xff0c;我整理和增加了自己的见解和代码贴图&#xff0…

【java学习—八】关键字static(4)

文章目录 1. 前言2. 关键字static3. 代码理解3.1. 类变量3.2. 类方法3.3. 工具类3.4. 总结 4. 注意事项 1. 前言 当我们编写一个类时&#xff0c;其实就是在描述其对象的属性和行为&#xff0c;而并没有产生实质上的对象&#xff0c;只有通过 new 关键字才会产生出对象&#xf…

10-16/10-17 JavaWeb入门/servlet

JavaWeb 现实生活中的互联网项目都是javaWeb项目, 包含网络, 多线程, 展示: HTML等其他的前端技术, 界面窗体展示(Swing包,AWT包 窗体), C#, JAVAWeb架构:(面试重点&#xff1a;要求记忆) B/S: 浏览器/服务器 优点: 以浏览器作为客户端, 使用这个软件, 用户不需要下载客户端,…

Spring Cloud之服务熔断与降级(Hystrix)

目录 Hystrix 概念 作用 服务降级 简介 使用场景 接口降级 服务端服务降级 1.添加依赖 2.定义接口 3.实现接口 4.Controller类使用 5.启动类添加注释 6.浏览器访问 客户端服务降级 1.添加依赖 2.application.yml 中添加配置 3.定义接口 4.Controller类使用 …

Chat Towards Data Science|如何用个人数据知识库构建 RAG 聊天机器人?

生成式人工智能时代&#xff0c;开发者可以借助大语言模型&#xff08;LLM&#xff09;开发更智能的应用程序。然而&#xff0c;由于有限的知识&#xff0c;LLM 非常容易出现幻觉。检索增强生成&#xff08;RAG&#xff09;https://zilliz.com/use-cases/llm-retrieval-augment…

TS 入门指南

TS 类型基本用法 TS简介 TypeScript&#xff0c;简称 TS&#xff0c; 是一种由微软开发的编程语言&#xff0c;它是对 JavaScript 的一个增强让我们更加方便地进行类型检查和代码重构&#xff0c;提高代码的可靠性和可维护性同时&#xff0c;TypeScript 还支持 ECMAScript 的…

对长度为n的顺序表L,编写一个时间复杂度为O(n),空间复杂度为O(1)的算法,该算法删除线性表中的所有值为x的数据元素

对长度为n的顺序表L&#xff0c;编写一个时间复杂度为O(n)&#xff0c;空间复杂度为O(1)的算法&#xff0c;该算法删除线性表中的所有值为x的数据元素 算法思路&#xff1a; 用count标记遇到x的次数&#xff0c;每次遇到x&#xff0c;count 遇到非x的元素&#xff0c;把它前移…

探索企业基本信息查询API:数据访问的便捷方式

前言 当涉及到获取企业的基本信息时&#xff0c;传统的方法往往需要大量的时间和人力资源&#xff0c;以收集、整理和验证数据。然而&#xff0c;现在有一种便捷的方式可以解决这个问题&#xff0c;那就是通过企业基本信息查询API。本文将探讨这种API是如何成为数据访问的便捷…

【Hive SQL】字符串操作函数你真的会用吗?

文章目录 ININSTRSUBSTRLOCATELIKE 前言&#xff1a; 今天在做一个需求的时候&#xff0c;需要判断字符串中是否包含一个子串&#xff0c;然后我发现了我平常没注意到的一个点&#xff0c;通过这篇博文来记录一下。 IN IN 函数用于判断一个元素是否存在于所给的元素组中&…

【基础架构设计】仿12306系统公共组件设计深度解析

仿12306系统学习 学习路线 12306 铁路购票系统学习总体分为三块&#xff1a;组件库开发、业务梳理以及业务系统开发。 组件库开发 组件库的产出源于对公共功能的封装&#xff0c;避免了在不同项目之间相互复制代码的情况。当然&#xff0c;如果这种复制代码的方式出现问题&a…

红酒种类及更多的红酒基本知识

从法国不拘一格的绿色牧场到北加州的金山&#xff0c;各种不同类型的红葡萄酒从淡色到豪放&#xff0c;从各种不同的种植地区走向世界各地的餐桌。来自云仓酒庄品牌雷盛红酒分享红葡萄酒在味道、酒体、颜色、香味以及它们提供的整体体验方面可以有很大的不同。 为合适的场合选…

amr文件苹果手机怎么打开?四个方法教会你!

Amr格式文件因其资源占用率低、传输方便&#xff0c;通常用作各大手机厂商广泛使用的一种保存录音文件的格式&#xff0c;非常适合制作来电铃声。但是amr音频格式的适用范围有限&#xff0c;很多时候就需要把amr转换成兼容性更好的mp3格式。如何将AMR文件转换为mp3然后在苹果手…

Linux 内核文件系统dentry_path_raw函数

文章目录 一、简介1.1 __dentry_path1.2 prepend_name1.3 d_path 二、dmeo参考资料 一、简介 // linux-5.4.18/fs/d_path.cchar *dentry_path_raw(struct dentry *dentry, char *buf, int buflen) {return __dentry_path(dentry, buf, buflen); } EXPORT_SYMBOL(dentry_path_r…

语雀停服8小时,P0级事故,故障原因和补偿来了。

昨天互联网圈子里发生了一件大事&#xff0c;那就是语雀的 P0 级事故&#xff0c;前后足足停服了 7 个多小时&#xff0c;放眼整个互联网的发展史&#xff0c;都是相当炸裂的表现。 语雀是技术大牛玉伯在蚂蚁金服内部孵化出来的一个云端知识库&#xff0c;整体的界面非常清爽&…

[SQL开发笔记]IN操作符: 在WHERE子句中规定多个值

上一实例我们在where子句使用(year2022 or year2020)&#xff0c;如果我们需要在WHERE子句中规定多个值呢&#xff1f;这时我们将学习IN操作符 一、功能描述&#xff1a; 在WHERE子句中规定多个值。 二、IN操作符语法详解&#xff1a; IN操作符语法&#xff1a; SELECT col…