<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh_CN">
  <title>Syrena</title>
  <subtitle>Blog</subtitle>
  <link href="https://blog.syrena.cn/" rel="alternate" type="text/html"/>
  <link href="https://blog.syrena.cn/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://blog.syrena.cn/</id>
  <updated>2026-10-06T00:00:00.000Z</updated>
  <entry>
    <title>Java 文件与 I/O 流：File、字节流、字符流与文件操作</title>
    <link href="https://blog.syrena.cn/posts/javase-iostream/javase-iostream/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javase-iostream/javase-iostream/</id>
    <published>2026-10-06T00:00:00.000Z</published>
    <updated>2026-10-06T00:00:00.000Z</updated>
    <summary>梳理 File 文件与目录操作、字节流和字符流的常用 API、资源关闭与字符编码，并通过目录扫描、文件复制和内容搜索案例练习文件操作。</summary>
    <content type="html"><![CDATA[<p><strong>从开发的角度，文件分为两类</strong></p>
<ol>
<li>文本文件</li>
<li>二进制文件</li>
</ol>
<blockquote>
<p>文本文件是指，文件中的数据按照某种字符编码（UTF-8、GBK）存储，本质上是一系列字符。当然，字面意思就是，文件里保存的是<strong>人可以直接阅读的文字</strong>。</p>
</blockquote>
<blockquote>
<p>二进制文件是指，文件中的数据以二进制形式直接保存，不按照字符编码解析。</p>
</blockquote>
<p>Java 标准库中提供了一系列的类操作文件</p>
<ul>
<li>文件系统操作（创建文件、删除文件、重命名、创建目录）</li>
<li>文件内容操作（针对一个文件的内容进行读和写）</li>
</ul>
<p>Java 中通过<code>java.io.File</code>类对一个文件（包括目录）进行抽象的描述。注意，有 File 对象，并不代表真实存在该文件。</p>
<h2>1.File 概述</h2>
<h3>属性</h3>
<table>
<thead>
<tr>
<th><strong>修饰符及类型</strong></th>
<th><strong>属性</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>static String</code></td>
<td><code>pathSeparator</code></td>
<td>依赖于系统的路径列表分隔符，以 <code>String</code>类型表示</td>
</tr>
<tr>
<td><code>static char</code></td>
<td><code>pathSeparatorChar</code></td>
<td>依赖于系统的路径列表分隔符，以 <code>char</code>类型表示</td>
</tr>
</tbody>
</table>
<h3>构造方法</h3>
<table>
<thead>
<tr>
<th><strong>签名</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>File(File parent, String child)</code></td>
<td>根据父目录的 <code>File</code>对象和子路径，创建新的 <code>File</code>实例</td>
</tr>
<tr>
<td><code>File(String pathname)</code></td>
<td>根据路径创建新的 <code>File</code>实例，路径可以是绝对路径或相对路径</td>
</tr>
<tr>
<td><code>File(String parent, String child)</code></td>
<td>根据父目录路径字符串和子路径，创建新的 <code>File</code>实例</td>
</tr>
</tbody>
</table>
<p>String 类型的参数是文件的路径，使用绝对/相对路径均可~</p>
<h3>方法</h3>
<table>
<thead>
<tr>
<th><strong>返回值类型</strong></th>
<th><strong>方法签名</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>String</code></td>
<td><code>getParent()</code></td>
<td>返回父路径；没有父路径时返回 <code>null</code></td>
</tr>
<tr>
<td><code>String</code></td>
<td><code>getName()</code></td>
<td>返回文件或目录的名称</td>
</tr>
<tr>
<td><code>String</code></td>
<td><code>getPath()</code></td>
<td>返回 <code>File</code>对象描述的路径</td>
</tr>
<tr>
<td><code>String</code></td>
<td><code>getAbsolutePath()</code></td>
<td>返回绝对路径</td>
</tr>
<tr>
<td><code>String</code></td>
<td><code>getCanonicalPath()</code></td>
<td>返回规范化的绝对路径</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>exists()</code></td>
<td>判断对应的文件或目录是否真实存在</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>isDirectory()</code></td>
<td>判断对应的对象是否为目录</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>isFile()</code></td>
<td>判断对应的对象是否为普通文件</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>createNewFile()</code></td>
<td>创建空文件；成功创建返回 <code>true</code>，目标已存在返回 <code>false</code></td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>delete()</code></td>
<td>删除文件或空目录；成功返回 <code>true</code></td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>deleteOnExit()</code></td>
<td>登记在 JVM 正常退出时尝试删除对应的文件或目录</td>
</tr>
<tr>
<td><code>String[]</code></td>
<td><code>list()</code></td>
<td>返回目录中直接子文件和子目录的名称</td>
</tr>
<tr>
<td><code>File[]</code></td>
<td><code>listFiles()</code></td>
<td>返回目录中直接子文件和子目录对应的 <code>File</code>对象</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>mkdir()</code></td>
<td>创建目标目录，不创建缺失的父目录</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>mkdirs()</code></td>
<td>创建目标目录，必要时一并创建缺失的父目录</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>renameTo(File dest)</code></td>
<td>重命名或移动到目标路径；成功返回 <code>true</code></td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>canRead()</code></td>
<td>判断当前程序是否可以读取对应的文件或目录</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>canWrite()</code></td>
<td>判断当前程序是否可以写入对应的文件或目录</td>
</tr>
</tbody>
</table>
<h2>2. 文件内容的读写--数据流</h2>
<p>Java 中提供了几十个这样的流，在此我只讲解其中 8 个就足够了~</p>
<p>针对上述几十个流，分成两个大类别：</p>
<ol>
<li><strong>字节流</strong> 读写文件，以字节为单位。是针对二进制文件使用的
<ol>
<li>InputStream 输入</li>
<li>OutputStream 输出</li>
</ol>
</li>
<li><strong>字符流</strong> 读写文件，以字符为单位。是针对文本文件使用的
<ol>
<li>Reader 输入</li>
<li>Writer 输出</li>
</ol>
</li>
</ol>
<p>关于判断输入输出的方法：</p>
<p>屁股坐在 CPU 上，迎面而来是输入，离你而去是输出。</p>
<h3>InputStream 概述</h3>
<p><strong>方法</strong></p>
<table>
<thead>
<tr>
<th>修饰符及返回值类型</th>
<th>方法签名</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>int</td>
<td><code>read()</code></td>
<td>读取一个字节的数据，返回 <code>-1</code>代表已经完全读完了</td>
</tr>
<tr>
<td>int</td>
<td><code>read(byte[] b)</code></td>
<td>最多读取 <code>b.length</code>字节的数据到 <code>b</code>中，返回实际读取的数量，<code>-1</code>代表已经读完了</td>
</tr>
<tr>
<td>int</td>
<td><code>read(byte[] b, int off, int len)</code></td>
<td>最多读取 <code>len - off</code>字节的数据到 <code>b</code>中，放在从 <code>off</code>开始的位置，返回实际读取的数量，<code>-1</code>代表已经读完了</td>
</tr>
<tr>
<td>void</td>
<td><code>close()</code></td>
<td>关闭字节流</td>
</tr>
</tbody>
</table>
<p>注意：</p>
<p><code>InputStream</code> 只是一个抽象类，要使用还需要具体的实现类。关于 <code>InputStream</code> 的实现类有很多，基本可以认为不同的输入设备都可以对应一个 InputStream 类，现在只关心从文件中读取，所以使用 <code>FileInputStream</code>（<code>InputStream</code> 的子类）</p>
<h3>FileInputStream 概述</h3>
<p><strong>构造方法</strong></p>
<table>
<thead>
<tr>
<th>签名</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FileInputStream(File file)</code></td>
<td>利用 <code>File</code>构造文件输入流</td>
</tr>
<tr>
<td><code>FileInputStream(String name)</code></td>
<td>利用<strong>文件路径</strong>构造文件输入流</td>
</tr>
</tbody>
</table>
<p><code>InputStream inputStream = new FileInputStream("路径")</code>当此处创建对象操作一旦成功，就相当于“打开文件”，同样还有关闭文件的方法，<code>inputStream.close()</code></p>
<p>虽然在 Java 中只需要申请内存资源即可，释放交给 GC 自动完成。但是，文件资源不等同于内存资源，虽然 GC 能够自动管理内存，但是不能自动管理文件，需要我们手动释放。若不手动释放，就会引起“文件资源泄露”，也就是俗话说的“占着茅坑不拉屎”</p>
<p>因为 进程 -&gt; PCB -&gt; 文件描述符表</p>
<p>每次程序打开一个文件，就会在文件描述符表（固定长度的顺序表）中申请一个表项（占个坑），然后如果光打开不关闭，就会使这里的文件描述符表的表项耗尽。后续再次打开就会失败~</p>
<p>至于为什么文件描述符表不能自动扩容，因为如果这么做的话，会更推进一步去加大内存操作中的不可控的因素~</p>
<p><strong>注意：</strong></p>
<p>另外，再补充一处，关于 InputStream 创建对象的时候一般使用 try-with-resource（Java1.5 引入的语法，我在 JDBC 也已经讲过） 去包裹。以保证最终走出 try 代码块的时候能够自动关闭并释放。（可以不只实现一个对象~）</p>
<p><strong>强调：能使用此方法创建的类必须实现 Closable 接口</strong></p>
<p>使用例子如下：</p>
<pre><code>public static void main(String[] args) throws IOException {
    try (InputStream inputStream 
         = new FileInputStream("./test.txt")) {
        while (true) {
            int data = inputStream.read();
            if(data == -1) {
                // 文件读完
                break;
            }
            System.out.printf("0x%x\n",data);
        }
    }
}
</code></pre>
<p>IntputStream 中的三个 read 方法</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>含义</th>
<th>返回值</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>read()</code></td>
<td>一次读取 1 个字节</td>
<td>字节值 <code>0~255</code>，结束返回 <code>-1</code></td>
</tr>
<tr>
<td><code>read(byte[] b)</code></td>
<td>一次读取多个字节</td>
<td>实际读取的<strong>字节数</strong>，结束返回 <code>-1</code></td>
</tr>
<tr>
<td><code>read(byte[] b, int off, int len)</code></td>
<td>读取数据到数组指定位置</td>
<td>实际读取的<strong>字节数</strong>，结束返回 <code>-1</code></td>
</tr>
</tbody>
</table>
<p>对于无参数的 read 方法，如果没读完就会返回读到的字节值，读完了就返回-1</p>
<p>注意：剩下两个有参数的 read 返回的是读取到的字节数。仔细想想会比较特殊，它使用的是 参数作为方法的返回值~ 在 Java 中比较少见，这种称之为“输出型参数”。一般来说，把方法想象成工厂，返回值就是产品；</p>
<pre><code>public static void main(String[] args) throws IOException {
    try (InputStream inputStream 
         = new FileInputStream("./test.txt")) {
        while (true) {
            byte[] data = new byte[1024];
            int n = inputStream.read(data);
            if (n == -1) {
                // 文件读完
                break;
            }
            for (int i = 0; i &lt; n; i++) {
                System.out.printf("0x%x\n",data[i]);
            }
        }
    }
}
</code></pre>
<p>Java 中，如果参数是引用类型，方法内部修改对象的时候，能影响到方法外部~ 即：</p>
<blockquote>
<p>当方法参数是引用类型时，方法拿到的是“对象引用的副本”。虽然引用本身是复制的，但这个副本和方法外面的引用仍然指向同一个对象，所以通过它修改对象内部的数据，外面能看到变化。</p>
</blockquote>
<p>对于第三个 read 方法：<code>read(byte[] b, int off, int len)</code></p>
<p>其中 off 是指 offset 偏移量（数组下标），放置 len 数量个~</p>
<h3>OutpuStream 概述</h3>
<p><strong>方法</strong></p>
<table>
<thead>
<tr>
<th>修饰符及返回值类型</th>
<th>方法签名</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>void</code></td>
<td><code>write(int b)</code></td>
<td>写入一个字节的数据</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>write(byte[] b)</code></td>
<td>将 <code>b</code>这个字节数组中的数据全部写入输出流中</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>write(byte[] b, int off, int len)</code></td>
<td>将字节数组 <code>b</code>中从 <code>off</code>位置开始的 <code>len</code>个字节写入输出流中</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>close()</code></td>
<td>关闭字节输出流，并释放与该流相关的系统资源</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>flush()</code></td>
<td>刷新输出流，将缓冲区中尚未真正写出的数据立即写入目标设备</td>
</tr>
</tbody>
</table>
<p>说明一下：<code>OutputStream</code> 同样只是一个抽象类，要使用还需要具体的实现类。我们现在还是只关心写入文件中，<strong>所以使用</strong> <code>FileOutputStream</code></p>
<p>此处的 writer 方法与前面的 read 方法类似，分为单个字节写入，多个字节写入以及多个字节拼接写入~</p>
<p>另外，对于<code>OutputStream</code> 来说，默认情况下会默认创建该文件。并且是以覆盖形式去写入。如果想要避免此情况，就需要使用追加写，<strong>即使用该构造方法</strong><code>FileOutputStream(File file, boolean append)</code></p>
<h3>FileOutputStream 概述</h3>
<p>构造方法：</p>
<table>
<thead>
<tr>
<th>构造方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FileOutputStream(String name)</code></td>
<td>根据文件路径创建字节输出流。文件不存在会创建；文件已存在会<strong>清空原内容</strong></td>
</tr>
<tr>
<td><code>FileOutputStream(String name, boolean append)</code></td>
<td>根据文件路径创建字节输出流，并指定是否追加写入</td>
</tr>
<tr>
<td><code>FileOutputStream(File file)</code></td>
<td>根据 <code>File</code> 对象创建字节输出流。文件已存在时会<strong>清空原内容</strong></td>
</tr>
<tr>
<td><code>FileOutputStream(File file, boolean append)</code></td>
<td>根据 <code>File</code> 对象创建字节输出流，并指定是否追加写入</td>
</tr>
<tr>
<td><code>FileOutputStream(FileDescriptor fdObj)</code></td>
<td>根据已有的文件描述符创建输出流，实际开发中较少直接使用</td>
</tr>
</tbody>
</table>
<pre><code>public static void main(String[] args) {
        try (OutputStream outputStream = new FileOutputStream("./output.txt")){
            byte[] bytes = {97,98,99};
            outputStream.write(bytes);

        } catch (IOException e) {
            // 此处是需要处理两个异常. 由于此处并没有针对这两个异常提供不同的处理, 就直接合并了.
            throw new RuntimeException(e);
        }
    }
</code></pre>
<h3>Reader 概述</h3>
<p>构造方法：</p>
<table>
<thead>
<tr>
<th>修饰符</th>
<th>构造方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>protected</code></td>
<td><code>Reader()</code></td>
<td>创建一个新的字符输入流对象，默认以当前 Reader 对象作为同步锁</td>
</tr>
<tr>
<td><code>protected</code></td>
<td><code>Reader(Object lock)</code></td>
<td>创建字符输入流，并指定用于同步操作的锁对象</td>
</tr>
</tbody>
</table>
<p>因为他们是<code>protected</code>，而且<code>Reader</code>是抽象类,<strong>所以通常不直接使用这两个构造方法</strong>。</p>
<p>方法：</p>
<table>
<thead>
<tr>
<th>返回值类型</th>
<th>方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>int</code></td>
<td><code>read()</code></td>
<td>一次读取<strong>一个字符</strong>，读取到末尾返回 <code>-1</code></td>
</tr>
<tr>
<td><code>int</code></td>
<td><code>read(char[] cbuf)</code></td>
<td>一次读取多个字符到字符数组中，返回实际读取的字符数量；末尾返回 <code>-1</code></td>
</tr>
<tr>
<td><code>int</code></td>
<td><code>read(char[] cbuf, int off, int len)</code></td>
<td>从字符数组的 <code>off</code>位置开始，最多存入 <code>len</code>个字符</td>
</tr>
<tr>
<td><code>long</code></td>
<td><code>skip(long n)</code></td>
<td>跳过 <code>n</code>个字符，返回实际跳过的字符数量</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>ready()</code></td>
<td>判断当前字符流是否已经可以读取</td>
</tr>
<tr>
<td><code>boolean</code></td>
<td><code>markSupported()</code></td>
<td>判断当前流是否支持 <code>mark()</code>操作</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>mark(int readAheadLimit)</code></td>
<td>标记当前读取位置</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>reset()</code></td>
<td>回到最近一次 <code>mark()</code>标记的位置</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>close()</code></td>
<td>关闭字符输入流并释放相关资源</td>
</tr>
</tbody>
</table>
<p>以上的 read 方法与前面的还是类似。</p>
<p>无参数版本的 read 一次读一个字符。返回的相当于是一个 char，两个字节。</p>
<p>char[] 数组版本是读字符数组，尽可能把字符数组给填满，返回实际读到的字符个数。</p>
<p>第三个版本的 read 中的 CharBuffer，其实就相当于 char[] 进行了封装。</p>
<pre><code>public static void main(String[] args) {
        try (Reader reader = new FileReader("./test.txt")) {
            while (true) {
                int c = reader.read();
                if(c == -1) {
                    break;
                }
                System.out.println((char) c);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
</code></pre>
<p>假设./test.txt 文件中，是“你好”两个字</p>
<p>但如果我不是强转为 char 类型，那么得到的结果就是<code>20320</code> 和 <code>22909</code>。那么之前还说过 utf8 中汉字占 3 个字节，为什么这里得到的是两组数字？</p>
<p>字节流读到的是文件中的原始数据，在硬盘上保存文件的时候，就是 6 个字节，utf8 编码。</p>
<p>字符流在读取的时候，就会根据文件的内容编码格式进行解析。read()一次，就会读到 3 个字节（按照 utf8 解析）。返回的时候，针对 3 个字节进行转码了，拿着 3 个字节查了一下 utf8 的码表“你”。read 就又把你这个汉字在 unicode 这个码表中再次查询一次，得到 unicode 的编码值。最终把 unicode 的编码值返回到 char 变量中（两个字节了）</p>
<p>以上就是自动进行转码了~</p>
<h3>Writer 概述</h3>
<p>构造方法</p>
<table>
<thead>
<tr>
<th>修饰符</th>
<th>构造方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>protected</code></td>
<td><code>Writer()</code></td>
<td>创建字符输出流，默认使用当前对象作为同步锁</td>
</tr>
<tr>
<td><code>protected</code></td>
<td><code>Writer(Object lock)</code></td>
<td>创建字符输出流，并指定同步锁对象</td>
</tr>
</tbody>
</table>
<p>因为<code>Writer</code>是抽象类，所以一般不会直接创建<code>Writer</code>对象。</p>
<p>方法</p>
<table>
<thead>
<tr>
<th>返回值类型</th>
<th>方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>void</code></td>
<td><code>write(int c)</code></td>
<td>写入一个字符</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>write(char[] cbuf)</code></td>
<td>写入一个字符数组中的全部字符</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>write(char[] cbuf, int off, int len)</code></td>
<td>写入字符数组中从 <code>off</code>开始的 <code>len</code>个字符</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>write(String str)</code></td>
<td>写入一个字符串</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>write(String str, int off, int len)</code></td>
<td>写入字符串中从 <code>off</code>开始的 <code>len</code>个字符</td>
</tr>
<tr>
<td><code>Writer</code></td>
<td><code>append(char c)</code></td>
<td>追加一个字符</td>
</tr>
<tr>
<td><code>Writer</code></td>
<td><code>append(CharSequence csq)</code></td>
<td>追加一个字符序列</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>flush()</code></td>
<td>刷新字符输出流，将缓冲的数据写出</td>
</tr>
<tr>
<td><code>void</code></td>
<td><code>close()</code></td>
<td>关闭输出流并释放资源</td>
</tr>
</tbody>
</table>
<p>上面看似是五个版本的 write 方法，其实也还是三个。</p>
<p>当前 IO 流对象，read 和 write 就属于直接读写文件了。要想提高效率：</p>
<ol>
<li>写代码的时候，手动床见缓冲区（byte[]），手动减少 read write 次数</li>
<li>使用标准库提供的“缓冲区流”BufferStream 把 InputStream 之类的对象套上一层</li>
</ol>
<h3>示例一</h3>
<p>扫描指定目录，并找到名称中包含指定字符的所有普通文件（不包含目录），并且后续询问用户是否要删除该文件</p>
<pre><code>package file;

import java.io.File;
import java.util.Scanner;

public class mySearchAndDelete {
    public void searchAndDelete () {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入要搜索的目录：");
        String rootDir = scanner.next();
        File rootfile = new File(rootDir);
        if (!rootfile.isDirectory()) {
            System.out.println("输入的不是目录！");
            return;
        }
        System.out.println("请输入要删除的关键字：");
        String keyWord = scanner.next();
        int count = scanDir(rootfile, keyWord, scanner);
        if (count == 0) {
            System.out.println("没有发现带有关键字的文件");
        } else {
            System.out.println("搜索完成，共发现 " + count + " 个匹配文件。");
        }
    }

    public int scanDir(File rootfile, String keyWord, Scanner scanner) {
        // 1. 列出当前目录中包含的内容
        File[] files = rootfile.listFiles();
        if (files == null) {
            return 0;
        }
        int count = 0;
        // 2. 遍历当前目录中的内容
        for (File file : files) {
            // 3. 判断当前文件是目录 or 文件
            if (!file.isFile()) {
                // 4. 如果是目录，继续遍历
                count += scanDir(file, keyWord, scanner);
            } else {
                // 5. 如果是文件，则查找关键字
                if (deleteFile(file, keyWord, scanner)) {
                    count++;
                }
            }
        }
        return count;
    }

    private boolean deleteFile(File file, String keyWord, Scanner scanner) {
        if (!file.getName().contains(keyWord)) {
            return false;
        }
        System.out.println(
                "发现文件: "
                        + file.getAbsolutePath()
                        + "，包含关键字！是否删除？(y/n)"
        );
        String input = scanner.next();

        if (input.equalsIgnoreCase("y")) {
            boolean success = file.delete();
            if (success)
                System.out.println("文件已删除！");
            else
                System.out.println("文件删除失败！");
        }
        return true;
    }
}

</code></pre>
<h3>示例二</h3>
<p>进行普通文件的复制</p>
<pre><code>package file;

import java.io.*;
import java.util.Scanner;

public class myCopyFile {
    public void copyFile () {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入源文件路径：");
        String srcPath = scanner.next();
        System.out.println("请输入目标文件路径：");
        String destPath = scanner.next();
        File srcFile = new File(srcPath);

        // 1. 先判断源文件是否存在
        if (!srcFile.isFile()) {
            System.out.println("源文件不存在或不是文件");
            return;
        }
        File destFile = new File(destPath);
        // 如果目标路径是一个已经存在的文件夹
        if (destFile.isDirectory()) {
            destFile = new File(destFile, srcFile.getName());
        }
        // 2. 判断目标文件是路径 or 文件（destPath不一定存在）
        File parentFile = destFile.getParentFile();
        if (parentFile != null &amp;&amp; !parentFile.isDirectory()) {
            System.out.println("目标文件所在目录不存在");
            return;
        }
        // 3. 此处进行读写操作
        try (InputStream inputStream = new FileInputStream(srcFile);
             OutputStream outputStream = new FileOutputStream(destFile)) {
            while (true) {
                byte[] buf = new byte[1024];
                int num = inputStream.read(buf);
                if (num == -1) {
                    break;
                }
                outputStream.write(buf, 0, num);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

</code></pre>
<h3>示例三</h3>
<p>扫描指定目录，并找到名称或者内容中包含指定字符的所有普通文件（不包含目录）</p>
<p>注意：当前现在的方案性能较差，所以尽量不要在太复杂的目录下或者大文件下实验</p>
<pre><code>package file;

import java.io.*;
import java.util.Scanner;

public class myFileSearcher {
    public void searchFile () {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入要搜索的目录：");
        String rootDir = scanner.next();
        File rootFile = new File(rootDir);
        if (!rootFile.isDirectory()) {
            System.out.println("输入的不是目录！");
            return;
        }
        System.out.println("请输入要搜索的关键字：");
        String keyWord = scanner.next();
        scanDir(rootFile, keyWord, scanner);
    }

    public static void scanDir (File rootFile, String keyWord, Scanner scanner) {
        // 1. 列出当前目录下所有内容
        File[] files = rootFile.listFiles();
        if (files == null) {
            return;
        }
        // 2. 遍历当前目录下的所有文件
        for (File file : files) {
            // 如果是文件
            if (file.isFile()) {
                dealFile(file, keyWord, scanner);
            } else {
                // 如果是目录
                scanDir(file, keyWord, scanner);
            }
        }
    }

    public static void dealFile(File file, String keyWord, Scanner scanner) {
        // 1. 判断文件名是否包含关键字
        if (file.getName().contains(keyWord)) {
            System.out.println("文件名包含关键字:" + file.getAbsolutePath());
        }
        // 2. 判断文件内容是否包含关键字。由于keyWord是字符串，就按照字符流读取
        StringBuilder stringBuilder = new StringBuilder();
        try (Reader reader = new FileReader(file)) {
            while (true) {
                char[] chars = new char[1024];
                int n = reader.read(chars);
                if (n == -1) {
                    break;
                }
                stringBuilder.append(chars, 0, n);
            }
        } catch (IOException e) {
            System.out.println("文件读取失败：" + file.getAbsolutePath());
            return;
        }
        // 3. 判定stringBuilder 是否包含关键字
        if (stringBuilder.indexOf(keyWord) &gt;= 0) {
            System.out.println("文件内容包含关键字: " + file.getAbsolutePath());
        }
        return;
    }
}

</code></pre>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java Socket 编程：TCP、UDP 与回显服务器实践</title>
    <link href="https://blog.syrena.cn/posts/javase-socket/javase-socket/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javase-socket/javase-socket/</id>
    <published>2026-10-06T00:00:00.000Z</published>
    <updated>2026-10-06T00:00:00.000Z</updated>
    <summary>从客户端与服务端模型出发，梳理 TCP、UDP 的特点与 Socket 常用 API，通过回显服务器和客户端示例理解网络通信、I/O 流与消息边界，并初步认识 I/O 多路复用。</summary>
    <content type="html"><![CDATA[<h2>1.网络编程</h2>
<p>网络编程，指通过编写程序，利用计算机网络实现不用设备或进程之间的数据通信。</p>
<p>可以理解为，网络编程就是让两台或多台计算机上的程序能够通过网络交换数据~</p>
<p>就比如像是：</p>
<ul>
<li>微信聊天：用户发送消息，服务器将消息转发给接收方。</li>
<li>浏览网页：浏览器向服务器发送 HTTP 请求，服务器返回网页内容。</li>
</ul>
<h3>1.1 基本模型</h3>
<p>网络编程中最常见的就是客户端/服务端模型，也就是 Client/Server 简称 C/S</p>
<p>但首先需要先了解<strong>发送端</strong>和<strong>接收端</strong>：</p>
<p>发送端：数据的发送方进程，称为发送端。发送端主机即网络通信中的源主机</p>
<p>接收端：数据的接收方进程，称为接收端。接收端主机即网络通信中的目的主机</p>
<p>收发端：发送端和接收端两端，也称为收发端</p>
<p>注意：发送端和接收端只是相对的，只是一次网络数据传输产生数据流向的概念</p>
<p><img src="./01.png" alt="" /></p>
<p>接下来就到了<strong>服务端</strong>和<strong>客户端</strong>~</p>
<p>服务端：在常见的网络数据传输场景下，提供服务的一方进程，称为服务端，可以提供对外服务。</p>
<p>客户端：获取服务的一方进程，称为客户端。</p>
<p>模型就像下图中：</p>
<p><img src="./02.png" alt="" /></p>
<p>以浏览器访问网站为例：</p>
<ol>
<li>客户端发送请求：浏览器向服务器发送 HTTP 请求。</li>
<li>服务器接收请求：服务器接收请求并执行相应的业务逻辑。</li>
<li>服务器返回响应：服务器将处理结果通过网络发送给客户端。</li>
<li>客户端接收响应：浏览器接收数据，并将网页内容显示给用户。</li>
</ol>
<h2>2.Socket</h2>
<p>接下来才是正题~</p>
<p>浅浅了解计算机网络相关知识就可以知道，程序完成的事情就是<strong>把请求发过去，再把响应包收回来</strong>。</p>
<p>但关键是 Java 程序或者说代码自身不能自己从头控制网卡、组织所有协议头、处理整个网络通信过程。但操作系统已经提供了这些网络能力~</p>
<p>程序通过一组接口，就能够调用刚刚所说的部分功能。而 <strong>Socket 套接字就是操作系统提供的网络通信机制中的通信端点</strong>~</p>
<p>可以这样理解：<strong>创建和使用 Socket，就是让程序获得了一个用于收发网络数据的端点</strong>。</p>
<p>Socket api 这就是传输层给应用层提供的。</p>
<p>而 Java 又把这些能力封装成了类，所以在 Java 中就可以通过创建对象、调用方法来完成通信。</p>
<p><strong>而传输层本身就有两个很重要的协议</strong>~</p>
<p>分别是 TCP 和 UDP。这两个协议规定了传输层的通信方式。而 Socket 让程序能够使用这些通行能力。</p>
<p>另外 TCP 和 UDP 差别也非常大，编写代码的时候风格也是不同的~</p>
<ul>
<li>TCP 是有连接，可靠传输，面向字节流，全双工的</li>
<li>UDP 是无连接，不可靠传输，面向数据报，全双工的</li>
</ul>
<p>下面分别介绍一下这两个特点，当然 也是再温习一遍计算机网络啦</p>
<h3>2.1 TCP 协议特点</h3>
<ol>
<li>
<p>首先是<strong>有连接</strong>，TCP 在正式传输数据之前，需要现在通信双方之间建立逻辑连接。通过三次握手来建立连接；然后是在建立好的连接基础上交换数据；再然后就是断开连接，通常是通过四次握手完成也可能通过 RST 等方式终止连接
<img src="./03.png" alt="" />这里的连接不是物理线路，而是双方维护的通信状态，就比如序列号、缓冲区和窗口大小等~</p>
</li>
<li>
<p>可靠传输。TCP 能够保证，<strong>只要连接正常工作并且数据成功交付，接收方就能按照正确顺序获得完整、不重复的字节数据</strong>。TCP 主要就是通过这些机制来实现可靠传输：序列号、确认应答（ACK）、超时重传、校验和、接收端去重和重排序、流量控制、拥塞控制。就比如：发送方一次发送 A、B、C 三段数据，其中 B 在网络中丢失。
<img src="./04.png" alt="" />TCP 会检测到数据缺失，并通过重传等机制尝试恢复数据。另外需要注意的是：<strong>可靠传输不代表数据绝对不会丢失，也不代表一定传输成功</strong>。如果网络持续中断，TCP 最终可能超时或连接失败。它的可靠性主要体现在不会把缺失、乱序的数据当作完整、有序的数据流正常交付。</p>
</li>
<li>
<p>首先知道 TCP 是<strong>面向字节流</strong>的，<strong>TCP 将应用程序发送的数据看作一串连续的字节，而不是一条条独立的消息</strong>。<strong>假设</strong>我向对方先后发送两段内容，分别是<code>hello</code>、<code>world</code>。发送方执行了两次 <code>write()</code>，但接收方不一定恰好执行两次 <code>read()</code> 才能读完，意思就是他的一次读取不一定刚好对应发送方的一次发送。但最后接收方看到的还会是按顺序排列的字节<code>helloworld</code>。
<img src="./05.png" alt="" />这也就是为什么 TCP 通信中可能出现应用层所说的<strong>粘包</strong>、<strong>半包问题</strong>。本质原因就是 TCP 不保留应用程序每次写入时的消息边界。</p>
</li>
<li>
<p>最后就是全双工通信。通信双方可以在同一时间发送和接收数据。
<img src="./06.png" alt="" />例如在聊天程序中，客户端可以向服务器发送消息，同时服务器也可以向客户端发送消息，不必等待对方发送完毕。</p>
</li>
</ol>
<h3>2.2 UDP 协议</h3>
<p>UDP 同样具有四个特点，无连接、不可靠传输、面向数据报、全双工通信。</p>
<ol>
<li><strong>无连接</strong>，UDP 在发送数据之前，不需要像 TCP 一样通过三次握手建立连接。发送方只需要知道接收方的 IP 地址和端口号，就可以尝试发送数据报。</li>
</ol>
<pre><code>DatagramSocket socket = new DatagramSocket();

byte[] data = "Hello".getBytes();

DatagramPacket packet = new DatagramPacket(
        data,
        data.length,
        InetAddress.getByName("127.0.0.1"),
        8080
);

socket.send(packet);
</code></pre>
<p>这里创建 UDP 套接字后，直接调用 <code>send()</code> 发送数据报，无须先建立 TCP 那样的连接。<strong>注意</strong>了，<strong>不需要建立连接，并不意味着接收方一定能收到数据。</strong></p>
<ol>
<li>
<p>不可靠传输。UDP 不提供 TCP 那样的可靠传输保证。在传输过程中可能会出现丢失、乱序、重复、损坏或被丢弃。UDP 本身不提供确认应答、超时重传、按序交付等机制
<img src="./07.png" alt="" />UDP 并不是一定会丢包，而是协议本身不保证数据一定到达。</p>
</li>
<li>
<p>面向数据报。UDP 将应用程序每次发送的数据视为一个独立的数据报，保留各个数据报之间的边界。就比如，发送方连续发送：第一个数据报 Hello、第二个数据报 World。接收方不会像 TCP 一样把两个数据报当成一个连续的字节流交付。
<img src="./08.png" alt="" /></p>
</li>
</ol>
<p>至于全双工就可 TCP 一样了。</p>
<h3>2.3 UDP 数据包套接字编程</h3>
<h4>2.3.1 API 介绍</h4>
<p>我们都知道，计算机中的“文件”通常都会是一个“广义的概念”。文件还能代指一些硬件设备（操作系统管理硬件设备，也是抽象成文件进行统一管理的）</p>
<p>那么网卡 —&gt; socket 文件</p>
<p>在操作网卡的时候，流程就和操作普通文件差不多，也需要打开 -&gt; 读写 -&gt; 关闭</p>
<p>打开的时候也会在文件描述符表中分配一个表项</p>
<h5>DatagramSocket</h5>
<p>首先是 <code>DatagramSocket</code>类，它是 UDP Socket，用于发送和接收 UDP 数据报。</p>
<p>其<strong>构造方法</strong>如下：</p>
<table>
<thead>
<tr>
<th>构造方法</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>DatagramSocket()</code></td>
<td>创建 UDP 套接字，由系统分配一个可用的本地端口（一般用于客户端）</td>
</tr>
<tr>
<td><code>DatagramSocket(int port)</code></td>
<td>创建 UDP 套接字，绑定指定的本地端口（一般用于服务端）</td>
</tr>
</tbody>
</table>
<p>主要注意他的参数。</p>
<p>另外要补充，<strong>客户端通常不用手动指定的原因是</strong>，服务器通常需要一个实现约定的端口，客户端通常使用系统分配的端口。</p>
<p>我这样举个例子更好理解些：</p>
<blockquote>
<p>假如我在学校一食堂 18 号窗口开了一家餐饮。那么我向同学们发传单告诉了他们我的餐饮地址，于是同学就知道我的餐饮位置在哪就会来到我的餐饮店前。这就像是客户端需要知道服务端的 IP 地址以及端口号一样。</p>
</blockquote>
<blockquote>
<p>但是同学来到我窗口点了餐回到座位上时，通常座位会是随机性的，因为你不能保证你去的时候不一定没有人坐吧~所以就像是客户端一样，你也不能保证你指定的端口号是不是被其他的进程给占用了</p>
</blockquote>
<p><strong><code>DatagramSocket</code>的常用方法：</strong></p>
<table>
<thead>
<tr>
<th>方法</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>void send(DatagramPacket p)</code></td>
<td>发送数据报，不会阻塞等待，直接发送</td>
</tr>
<tr>
<td><code>void receive(DatagramPacket p)</code></td>
<td>接收数据报，默认会阻塞等待</td>
</tr>
<tr>
<td><code>int getLocalPort()</code></td>
<td>获取这个套接字使用的本地端口</td>
</tr>
<tr>
<td><code>void close()</code></td>
<td>关闭套接字，释放相关资源</td>
</tr>
</tbody>
</table>
<p>此处所说的阻塞等待是，假设服务器执行到了<code>socket.receive(packet);</code>但还没有收到数据。于是执行这个方法的线程就会等待，后面的代码暂时就不会执行。</p>
<hr />
<h5>DatagramPacket</h5>
<p>第二个类就是<code>DatagramPacket</code>了。<code>DatagramPacket</code> 用于封装 UDP 通信中发送或接收的数据报包。</p>
<p>它共有 6 个构造方法，可以分为接收数据和发送数据两类。</p>
<p><strong><code>DatagramPacket</code>用于接收的构造方法：</strong></p>
<table>
<thead>
<tr>
<th>构造方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>DatagramPacket(byte[] buf, int length)</code></td>
<td>创建接收数据报包，指定缓冲区和最大接收长度</td>
</tr>
<tr>
<td><code>DatagramPacket(byte[] buf, int offset, int length)</code></td>
<td>创建接收数据报包，指定缓冲区、起始偏移量和最大接收长度</td>
</tr>
</tbody>
</table>
<p>使用范例：</p>
<pre><code>byte[] buffer = new byte[4096];

DatagramPacket packet =
        new DatagramPacket(buffer, buffer.length);
</code></pre>
<p>此处就相当于准备好一个容器，“收到数据后，请把数据放进这个数组，本次最多接收这么多字节”</p>
<p>真正接收要调用：</p>
<pre><code>socket.receive(packet);
</code></pre>
<p>接收完成后，<code>packet</code> 中不仅有数据，还有发送方的 IP 和端口。</p>
<p><code>receive</code> 与之前的 read 方法一样，这也是一个把参数作为“输出结果”的一个输出型参数</p>
<p><strong><code>DatagramPacket</code>用于发送的构造方法：</strong></p>
<table>
<thead>
<tr>
<th>构造方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>DatagramPacket(byte[] buf, int length, InetAddress address, int port)</code></td>
<td>指定数据、长度、目标 IP 地址和端口</td>
</tr>
<tr>
<td><code>DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port)</code></td>
<td>指定数据、起始偏移量、长度、目标 IP 地址和端口</td>
</tr>
<tr>
<td><code>DatagramPacket(byte[] buf, int length, SocketAddress address)</code></td>
<td>指定数据和长度，使用 <code>SocketAddress</code>&lt;br/&gt; 指定目标地址（包含 IP 地址和端口）</td>
</tr>
<tr>
<td><code>DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)</code></td>
<td>指定数据、起始偏移量和长度，使用 <code>SocketAddress</code>&lt;br/&gt; 指定目标地址（包含 IP 地址和端口）</td>
</tr>
</tbody>
</table>
<p>涉及到的一些参数说明：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>buf</code></td>
<td><code>byte[]</code></td>
<td>存放发送或接收数据的字节数组</td>
</tr>
<tr>
<td><code>offset</code></td>
<td><code>int</code></td>
<td>数据在数组中的起始下标</td>
</tr>
<tr>
<td><code>length</code></td>
<td><code>int</code></td>
<td>发送数据的长度，或允许接收的最大长度，单位为字节</td>
</tr>
<tr>
<td><code>address</code></td>
<td><code>InetAddress</code></td>
<td>目标 IP 地址</td>
</tr>
<tr>
<td><code>port</code></td>
<td><code>int</code></td>
<td>目标端口号</td>
</tr>
<tr>
<td><code>address</code></td>
<td><code>SocketAddress</code></td>
<td>封装目标 IP 地址和端口号</td>
</tr>
</tbody>
</table>
<p><code>DatagramPacket</code>常用方法：</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>byte[] getData()</code></td>
<td>获取存放数据的数组</td>
</tr>
<tr>
<td><code>int getOffset()</code></td>
<td>获取有效数据的起始下标</td>
</tr>
<tr>
<td><code>int getLength()</code></td>
<td>获取待发送或实际收到的数据长度</td>
</tr>
<tr>
<td><code>InetAddress getAddress()</code></td>
<td>获取对方的 IP 地址</td>
</tr>
<tr>
<td><code>int getPort()</code></td>
<td>获取对方的端口号</td>
</tr>
<tr>
<td><code>SocketAddress getSocketAddress()</code></td>
<td>一次获取对方的地址信息</td>
</tr>
</tbody>
</table>
<h4>2.3.2 回显服务器示例</h4>
<h5>UDP Echo Server</h5>
<p>将服务器分为两部分，</p>
<ul>
<li><code>start()</code>：负责接收请求、处理请求、返回响应</li>
<li><code>process()</code>：负责具体业务</li>
</ul>
<pre><code>import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

public class UdpEchoServer {
    public DatagramSocket socket = null;

    public UdpEchoServer(int port) throws SocketException {
        socket = new DatagramSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动...");

        while (true) {
            // 1. 读取请求并解析
            // DatagramPacket 表示一个UDP数据包，此处传入的字节数组，就保存UDP的载荷部分
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestPacket);
            // 把读取到的二进制数据，转成字符串，只是构造有效的部分
            String request = new String(requestPacket.getData(),
                    0,
                    requestPacket.getLength());

            // 2.根据请求，计算响应（服务器中关键的逻辑）
            // 但此处目前只打算写回显服务器，该环节相当于省略
            String response = process(request);

            // 3.把响应返回给客户端
            // 根据response构造DatagramPacket，发送给客户端
            // 注意：此处不能使用response.length()
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
                    response.getBytes().length,
                    requestPacket.getSocketAddress());
            // 此处还不能直接发送，UDP协议滋生没有保存对方的信息（不知道发给谁）
            // 需要指定目的IP 和 目的端口
            socket.send(responsePacket);

            // 4. 打印一个日志
            System.out.printf("[%s:%d] req: %s, resp: %s\n", responsePacket.getAddress().toString(),
                    requestPacket.getPort(), request, response);
        }
    }

    private String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();
    }
}

</code></pre>
<h5>UDP Echo Client</h5>
<p>UDP 回显客户端，需要完成的如下：</p>
<ul>
<li>从键盘读取文字；</li>
<li>把文字发送给服务器；</li>
<li>接收服务器返回的文字；</li>
<li>显示结果；</li>
</ul>
<pre><code>import java.io.IOException;
import java.net.*;
import java.util.Scanner;

public class UdpEchoClient {
    private DatagramSocket socket = null;
    private String serverIp;
    private int serverPort;

    public UdpEchoClient (String serverIp, int serverPort) throws SocketException {
        this.serverIp = serverIp;
        this.serverPort = serverPort;
        socket = new DatagramSocket();
    }

    public void start () throws IOException {
        Scanner scanner = new Scanner(System.in);
        try {
            while (true) {
                // 1. 从控制台读取用户输入的内容
                System.out.println("请输入要发送的内容：");
                if (!scanner.hasNext()) {
                    break;
                }
                String request = scanner.next();
                // 2. 把请求发送给服务器，需要构造 DatagramPacket 对象
                // 构造过程中，不光要构造载荷，还要设置服务器的IP和端口号
                DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),
                        request.getBytes().length,
                        InetAddress.getByName(serverIp),
                        serverPort);
                // 3. 发送数据报
                socket.send(requestPacket);
                // 4. 接收服务器的响应
                DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
                socket.receive(responsePacket);
                // 5. 从服务器读取的数据进行解析，并打印
                String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
                System.out.println(response);
            }
        } finally {
            socket.close();
        }
    }

    public static void main(String[] args) throws IOException {
        UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}
</code></pre>
<p>就可以得到这样一个运行效果~</p>
<p><img src="./09.png" alt="" /></p>
<p>当然，如果有云服务器的话，可以自己打一个 jar 包放到云服务器上，并启动该程序：</p>
<pre><code>java -jar jar包名
</code></pre>
<p>此时该程序就在服务器上运行起来了，只要能访问该服务器的都可以直接进行回显打印~</p>
<h3>2.4 TCP 流套接字编程</h3>
<h4>2.4.1 API 介绍</h4>
<h5>ServerSocket</h5>
<p><code>ServerSocket</code>是 TCP <strong>服务端</strong>用的核心类，作用是<strong>在服务器端监听某个端口，并等待客户端建立 TCP 连接。也就是说是专门给服务器用的</strong></p>
<p><strong>常用构造方法：</strong></p>
<table>
<thead>
<tr>
<th>构造方法</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ServerSocket()</code></td>
<td>创建一个尚未绑定端口的 <code>ServerSocket</code></td>
</tr>
<tr>
<td><code>ServerSocket(int port)</code></td>
<td>创建服务器 Socket，并绑定指定端口</td>
</tr>
<tr>
<td><code>ServerSocket(int port, int backlog)</code></td>
<td>绑定端口，并指定连接等待队列长度</td>
</tr>
<tr>
<td><code>ServerSocket(int port, int backlog, InetAddress bindAddr)</code></td>
<td>绑定指定端口、等待队列长度和本机 IP 地址</td>
</tr>
</tbody>
</table>
<p>通常都会直接先指定好端口：</p>
<pre><code>ServerSocket serverSocket = new ServerSocket(9090);
</code></pre>
<p><strong>常用方法：</strong></p>
<table>
<thead>
<tr>
<th>方法</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>accept()</code></td>
<td>等待客户端连接，并返回一个 <code>Socket</code></td>
</tr>
<tr>
<td><code>bind(SocketAddress endpoint)</code></td>
<td>绑定 IP 和端口</td>
</tr>
<tr>
<td><code>close()</code></td>
<td>关闭服务器 Socket</td>
</tr>
<tr>
<td><code>isClosed()</code></td>
<td>判断是否已经关闭</td>
</tr>
<tr>
<td><code>isBound()</code></td>
<td>判断是否已经绑定地址</td>
</tr>
<tr>
<td><code>getLocalPort()</code></td>
<td>获取当前监听端口</td>
</tr>
<tr>
<td><code>getInetAddress()</code></td>
<td>获取绑定的本机 IP</td>
</tr>
<tr>
<td><code>getLocalSocketAddress()</code></td>
<td>获取完整本地 Socket 地址</td>
</tr>
<tr>
<td><code>setSoTimeout(int timeout)</code></td>
<td>设置 <code>accept()</code> 最大阻塞时间</td>
</tr>
<tr>
<td><code>getSoTimeout()</code></td>
<td>获取当前超时时间</td>
</tr>
</tbody>
</table>
<p>其中，注意<code>accept()</code>方法是一个阻塞方法，它会开始监听指定端口，有客户端连接后，返回一个服务端 Socket 对象，并基于该 Socket 建立与客户端的连接，否则阻塞等待~</p>
<p>就比如：</p>
<pre><code>System.out.println("等待客户端连接...");
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接");
</code></pre>
<p>当执行到<code>accept()</code>的时候，如果暂时没有客户端连接，当前线程就会停在这里~</p>
<p><strong>注意，TCP 这里就没有 send/receive 这样的操作...</strong></p>
<h5>Socket</h5>
<p>这个类是客户端和服务端都能用。<code>Socket</code>是客户端或服务端中接收到服务端建立连接（accept）的请求后，<strong>返回的服务端 Socket</strong>。不管是客户端还是服务端，都是双方建立连接后，用来保存对端信息以及与对方收发数据的~</p>
<p><strong>常用构造方法：</strong></p>
<table>
<thead>
<tr>
<th>方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Socket()</code></td>
<td>创建一个尚未连接服务器的 Socket 对象，之后需要调用<code>connect()</code>建立连接</td>
</tr>
<tr>
<td><code>Socket(String host, int port)</code></td>
<td>根据服务器主机地址和端口号创建 Socket，并立即尝试建立 TCP 连接</td>
</tr>
<tr>
<td><code>Socket(InetAddress address, int port)</code></td>
<td>根据服务器的 <code>InetAddress</code>和端口号创建 Socket，并立即尝试建立 TCP 连接</td>
</tr>
<tr>
<td><code>Socket(String host, int port, InetAddress localAddr, int localPort)</code></td>
<td>指定服务器地址、服务器端口、本地 IP 和本地端口建立连接</td>
</tr>
<tr>
<td><code>Socket(InetAddress address, int port, InetAddress localAddr, int localPort)</code></td>
<td>使用 <code>InetAddress</code> 指定服务器地址，同时指定本地 IP 和本地端口建立连接</td>
</tr>
</tbody>
</table>
<p><strong>常用方法：</strong></p>
<table>
<thead>
<tr>
<th>方法</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>getInputStream()</code></td>
<td>获取输入流，<strong>接收数据</strong></td>
</tr>
<tr>
<td><code>getOutputStream()</code></td>
<td>获取输出流，<strong>发送数据</strong></td>
</tr>
<tr>
<td><code>getInetAddress()</code></td>
<td>获取对方 IP</td>
</tr>
<tr>
<td><code>getPort()</code></td>
<td>获取对方端口</td>
</tr>
<tr>
<td><code>getLocalPort()</code></td>
<td>获取本地端口</td>
</tr>
<tr>
<td><code>isConnected()</code></td>
<td>判断是否成功连接过</td>
</tr>
<tr>
<td><code>isClosed()</code></td>
<td>判断 Socket 是否关闭</td>
</tr>
<tr>
<td><code>close()</code></td>
<td>关闭 Socket</td>
</tr>
</tbody>
</table>
<p>到这里我再讲上面一起解释了：</p>
<p>在 TCP 中，常会这样去接收客户端发来的连接，你可以理解为这样去接电话~</p>
<pre><code>private ServerSocket serverSocket = null;
Socket clientSocket = serverSocket.accept();
</code></pre>
<blockquote>
<p>其实就是<code>ServerSocket</code>负责等客户端来连接，<code>Socket</code>负责连接成功之后真正和某个客户端通信</p>
</blockquote>
<p>我给个比喻：</p>
<p><code>private ServerSocket serverSocket = null</code>：理解为小哥在马路牙子上揽客 [ 外场 ]~</p>
<p><code>Socket clientSocket = serverSocket.accept()</code>：理解为小姐姐给客人提供点单服务 [ 内场 ]~</p>
<h4>2.4.2 回显服务器示例</h4>
<h5>TCP Echo Server</h5>
<pre><code>import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TcpEchoServer {
    private ServerSocket serverSocket = null;

    // 此处和UDP服务器类似，也是在构造对象的时候，绑定端口号
    public TcpEchoServer (int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start () throws IOException {
        System.out.println("服務器启动");
        // 此处为提供多个客户端服务，需要使用多线程来解决
        // 否则只能服务于一个客户端
        // 此处用线程池来解决，通常不会使用fixThreadPool
        ExecutorService executorService = Executors.newCachedThreadPool();
        while (true) {
            // 对TCP来说，需要先处理客户端发来的连接
            // 通过读写clientSocket，和客户端进行通信
            // 若无客户端发起连接，此时accept就会阻塞
            // 此处主线程负责进行accpet，每次accept到一个客户端，就创建一个线程
            Socket clientSocket = serverSocket.accept();

            executorService.submit(() -&gt; {
                processConnection(clientSocket);
            });
        }
    }

    // 该方法处理一个客户端的连接，但被线程池调用
    private void processConnection(Socket clientSocket) {
        System.out.printf("[%s,%d]客户端上线!\n",
                          clientSocket.getInetAddress(),
                          clientSocket.getPort());
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
            // 针对 InputStream 套了一层
            Scanner scanner = new Scanner(inputStream);
            // 针对 OutputStream 套了一层
            PrintWriter writer = new PrintWriter(outputStream);
            // 分成三个步骤
            while (true) {
                // 1. 读取请求并解析，可以直接read，也可以借助Scanner来辅助完成
                if (!scanner.hasNext()) {
                    // 连接断开
                    System.out.printf("[%s,%d] 客户端下线!\n",clientSocket.getInetAddress(),
                                      clientSocket.getPort());
                    break;
                }
                String request = scanner.next();
                // 2. 根据请求计算响应
                String response = process(request);
                // 3. 返回响应到客户端
                writer.println(response);
                writer.flush();
                // 打印日志
                System.out.printf("[%s:%d] req: %s, resp: %s\n",
                                clientSocket.getInetAddress(),
                                clientSocket.getPort(),
                                request,
                                response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

    private String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        TcpEchoServer server = new TcpEchoServer(1234);
        server.start();
    }
}

</code></pre>
<h5>TCP Echo Client</h5>
<pre><code>import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;

public class TcpEchoClient {
    private Socket socket = null;

    public TcpEchoClient(String serverIp, int serverPort) throws IOException {
        // 直接把字符串的IP地址设置进来
        socket = new Socket(serverIp, serverPort);
    }

    public void start() {
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {
            Scanner scannerNet = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);

            // 从控制台读取请求，发送给服务器
            while (true) {
                // 1. 从控制台读取用户输入
                String request = scanner.next();
                // 2. 发送给服务器
                writer.println(request);
                // 加上刷新缓冲区操作，才是真正发送数据
                writer.flush();
                // 3. 读取服务器返回的响应
                String response = scannerNet.next();
                // 4. 打印到控制台
                System.out.println(response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) throws IOException {
        TcpEchoClient client = new TcpEchoClient("127.0.0.1", 1234);
        client.start();
    }
}
</code></pre>
<h5>具体解释~</h5>
<ol>
<li><strong>Socket 问题</strong></li>
</ol>
<p>两部分代码整体的运行逻辑主要就是：</p>
<p><img src="./10.png" alt="" /></p>
<p>以上是客户端和服务端接收和发送的核心部分，主要就是需要处理清楚请求包和响应包之间的处理关系即可~</p>
<p><img src="./11.png" alt="" /></p>
<p>另外，客户端里的 <code>socket</code> 和 服务端接收客户端请求的 <code>socket</code> 不一样~</p>
<p>这俩 socket 对象不是同一个对象（分别是在不同进程中，甚至在不同主机上）</p>
<p>从更严谨的话来说就是：</p>
<blockquote>
<p>这两个 <code>Socket</code> 是两个不同的 Java 对象，分别位于客户端和服务端程序中，但它们代表同一条 TCP 连接的两个端点。</p>
</blockquote>
<p><img src="./12.png" alt="" /></p>
<p>结构就像是上面图中一样~</p>
<p>当然，用电话比喻也是没问题的。就像是 A 和 B 在进行一次通信，但 A 和 B 绝对不是同一个对象！！！</p>
<ol>
<li><strong>运行问题</strong></li>
</ol>
<p>如果是 UDP 和 TCP 一起运行同一个端口，实际上并不会受影响，主要是因为协议不同才不会冲突~</p>
<p>然后，客户端注意，需要进行配置修改才能多客户端运行：</p>
<p><img src="./13.png" alt="" /></p>
<p><img src="./14.png" alt="" /></p>
<p>这样就能成功运行多个实例了~</p>
<ol>
<li><strong>IO 流问题</strong></li>
</ol>
<pre><code>try (InputStream inputStream = socket.getInputStream();
    OutputStream outputStream = socket.getOutputStream()) {
        Scanner scannerNet = new Scanner(inputStream);
        PrintWriter writer = new PrintWriter(outputStream);
    ...
}
</code></pre>
<p>此处不是“又拿了一次 IO 流”，而是在原始字节流外面套了一层更方便使用的高级工具。</p>
<p>首先我们知道，<code>InputStream</code>是字节输入流，也就是从网络接收到的数据本质上就是：01001000...类似这种~也就是一个个字节。</p>
<p>如果是直接使用<code>InputStream</code>，通常还需要自己写：</p>
<pre><code>byte[] buffer = new byte[1024];
int n = inputStream.read(buffer);
String str = new String(buffer, 0, n);
</code></pre>
<p>所以才在这里套一层<code>Scanner</code>，它会将字节流处理为字符串，因此也就不需要自己处理 <code>byte[]</code>。</p>
<p><code>OutputStream</code>也是一样的道理。如果直接用它的话，就还需要：</p>
<pre><code>String request = "hello";
outputStream.write(request.getBytes());
</code></pre>
<p>但是我们现在发送的是文本，所以套一个<code>PrintWriter</code>之后就可以很自然的用 println 写入缓冲区，最后再 <code>flush</code> 即可。</p>
<p><img src="./15.png" alt="" /></p>
<p>总结上面就是，<code>Socket</code> 提供底层网络 IO 通道，<code>InputStream/OutputStream</code> 负责字节传输，而 <code>Scanner/PrintWriter</code> 是为了更方便地以文本形式使用这些字节流。</p>
<ol>
<li><code>flush()</code>必须出现</li>
</ol>
<p>代码中的部分：</p>
<pre><code>writer.println(request);
writer.flush();
</code></pre>
<p>需要特别理解一下，<code>PrintWriter</code>为了提高 IO 效率，会涉及缓冲。</p>
<p>当执行<code>writer.println(request)</code>这部分操作的时候，可以先理解为“把准备发送的数据交给<code>PrintWriter</code>”。而<code>flush</code>则是表示：“把还没有真正向下输出的数据立即刷到 <code>OutputStream</code>”</p>
<p>所以此处的两行代码基本就是属于固定搭配~</p>
<ol>
<li><code>println()</code>和 <code>hasNext</code> 的玄机</li>
</ol>
<p>注意客户端和服务端里的两部分代码：</p>
<pre><code>// 客户端
writer.println(request);
writer.flush();

// 服务端
if (!scanner.hasNext()) {
    ......
}
String request = scanner.next();
</code></pre>
<p>这之间形成了一套基于空白字符分隔消息的简单应用层协议~</p>
<p>首先要知道<strong>不能直接把 <code>println(request)</code> 改成 <code>print(request)</code></strong>。如果只是改成 <code>print(request)</code> 再 <code>flush()</code>，很可能服务器会一直阻塞在 <code>hasNext()</code>，因为它不知道这一条请求到底什么时候结束。</p>
<p>因为当我们使用<code>println</code>的时候，<code>println</code>会在后面追加换行符，类似相当于追加一个<code>\n</code>；更准确地说，<code>println()</code> 添加的是当前平台的行结束符。</p>
<pre><code>Linux:
hello\n

Windows:
hello\r\n
</code></pre>
<p>而服务端的玄机又在：</p>
<p>服务端的<code>Scanner</code>是<code>Scanner scanner = new Scanner(inputStream)</code>。而<code>Scanner</code>默认采用的分隔符是空白字符（空格、\t、\n、\r 等）</p>
<p><img src="./16.png" alt="" /></p>
<p>我让 GPT 给出了服务器大概解析的样子~</p>
<p>也就是说 scanner 的 <code>hasNext</code> 和 <code>next</code> 方法都是以分隔符作为判断隔断标准，以解析一段token/数据项</p>
<h2>3.IO 多路复用（了解一下~）</h2>
<p>如果客户端进一步增加，此时多线程/线程池，就会产生大量的线程~</p>
<p>IO 多路复用，<strong>就是让操作系统统一等待多个连接的 IO 事件，告诉程序哪些连接已经就绪，线程再去处理这些链接。</strong></p>
<p>在我们前面写的 TCP 回显服务器一开始是没有使用线程池来完成的，于是就导致了第二个第三个线程...一直在等待第一个线程完成直至断开连接，这样也才到第二个线程（当然也还是要竞争~）</p>
<p>于是乎就想出了使用线程池来让多个客户端一同完成服务~但是！这样就变成了每一个连接安排一个线程，各自等待自己的客户端。但如果连接很多，且大部分时间都没有消息，就会有很多线程在等待~</p>
<p>那么 IO 多路复用就是为了<strong>让一个线程照看多个连接，谁有数据，就先处理谁</strong>。</p>
<p><strong>拿一个餐厅比例：</strong></p>
<p>原来的方式就像是：</p>
<blockquote>
<p>每桌安排一个服务员，顾客还没决定吃什么，服务员一直站在这桌等</p>
</blockquote>
<p>多路复用的方式就像是：</p>
<blockquote>
<p>多个餐桌共用一个服务员，餐厅的呼叫系统告诉他哪桌需要服务，他再过去处理</p>
</blockquote>
<p>如果 B 桌和 C 桌都按了铃，这个服务员可以先处理 B，再处理 C。</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>网络通信基础：IP、端口、协议分层与数据封装</title>
    <link href="https://blog.syrena.cn/posts/javaee-network-basics/javaee-network-basics/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-network-basics/javaee-network-basics/</id>
    <published>2026-10-04T00:00:00.000Z</published>
    <updated>2026-10-04T00:00:00.000Z</updated>
    <summary>从进程间通信出发，理解 IP 地址、端口号、协议与五元组，梳理 OSI 和 TCP/IP 分层模型，以及数据发送、接收过程中的封装与分用。</summary>
    <content type="html"><![CDATA[<p>计算机连接起来以后，数据到底应该送到哪里，又应该怎样理解收到的数据？</p>
<p>围绕这里，就需要我们先了解四个概念：</p>
<ul>
<li>IP 地址</li>
<li>端口号</li>
<li>协议</li>
<li>五元组</li>
</ul>
<p>它们之间有联系，我们把它们放到同一个场景里理解。</p>
<h2>1. 网络通信基础</h2>
<p>我们平常会说，我的电脑正在和服务器通信，但再往里面看，真正需要交换数据的是：<strong>两边正在运行的程序，也就是进程</strong>。</p>
<p>就比如：</p>
<table>
<thead>
<tr>
<th><strong>场景</strong></th>
<th><strong>发起通信的程序</strong></th>
<th><strong>接收通信的程序</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>浏览网页</td>
<td>浏览器</td>
<td>网站服务器程序</td>
</tr>
<tr>
<td>使用 JDBC 查询数据库</td>
<td>Java 程序</td>
<td>MySQL 服务程序</td>
</tr>
<tr>
<td>聊天</td>
<td>聊天客户端</td>
<td>聊天服务器程序</td>
</tr>
</tbody>
</table>
<p>之前学习 JDBC 的时候就写过相关代码：</p>
<pre><code>DriverManager.getConnection(
    "jdbc:mysql://127.0.0.1:3306/java113",
    user,
    password
);
</code></pre>
<p>此处正是设计网络通信了，Java 程序通过 JDBC 驱动，与 MySQL 服务程序建立连接、交换数据。所以会说，<strong>网络通信更具体的说，是进程之间基于网络传输数据</strong>。</p>
<p>至于<strong>接收数据的程序在哪台设备上</strong>以及<strong>找到设备以后把数据交给哪个程序</strong>这就需要 IP 和端口号了。</p>
<h3>1.1 IP 地址</h3>
<p>IP 地址可以理解为，用于定位主机的网络地址</p>
<p>就像是寄快递填写的，某个城市、某条街道、某栋楼</p>
<p>同样，发送网络数据时，也需要有目标地址，类似192.168.1.20</p>
<p>更严谨地说，IP 地址配置在网络接口上，一台设备可以有多个 IP 地址，并不是每台电脑永远只有一个地址。</p>
<p>此处我们讨论的是 IPv4：</p>
<p><strong>IPv4 地址占 32 位，也就是 4 个字节，是一个 32 位的二进制数，通常分成四个 8 位部分。</strong></p>
<p>使用的是<strong>点分十进制表示法</strong></p>
<table>
<thead>
<tr>
<th>部分</th>
<th>占用位数</th>
<th>十进制范围</th>
</tr>
</thead>
<tbody>
<tr>
<td>第一部分</td>
<td>8 位</td>
<td>0～255</td>
</tr>
<tr>
<td>第二部分</td>
<td>8 位</td>
<td>0～255</td>
</tr>
<tr>
<td>第三部分</td>
<td>8 位</td>
<td>0～255</td>
</tr>
<tr>
<td>第四部分</td>
<td>8 位</td>
<td>0～255</td>
</tr>
</tbody>
</table>
<p>而常用的 127.0.0.1，则是一个回环地址，通常用于访问本地的网络服务~</p>
<p>而 localhost，则是主机名，但常被解析为127.0.0.1，当然也有可能解析为 IPv6 的 ::1</p>
<table>
<thead>
<tr>
<th>项目</th>
<th><code>localhost</code></th>
<th><code>127.0.0.1</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>类型</td>
<td>主机名</td>
<td>IPv4 地址</td>
</tr>
<tr>
<td>含义</td>
<td>本地主机</td>
<td>IPv4 回环地址</td>
</tr>
<tr>
<td>是否需要解析</td>
<td>需要</td>
<td>不需要</td>
</tr>
<tr>
<td>可能解析为</td>
<td><code>127.0.0.1</code> 或 <code>::1</code></td>
<td>永远是 IPv4</td>
</tr>
<tr>
<td>网络协议</td>
<td>可能 IPv4 / IPv6</td>
<td>IPv4</td>
</tr>
</tbody>
</table>
<p>对于当前很多 AI 小白利用 AI 做项目的时候，就常有将访问本地资源的<code>localhost</code>分享给他人而搞出段子~</p>
<p>你可以这样记住区别：</p>
<blockquote>
<p><code>localhost</code> 是主机名，<code>127.0.0.1</code> 是 IPv4 回环地址。<code>localhost</code> 通常会解析到 <code>127.0.0.1</code>，但也可能解析到 IPv6 的 <code>::1</code>，所以两者不能认为绝对完全相同。</p>
</blockquote>
<h3>1.2 端口号</h3>
<p>端口号就是负责，数据发送到目标主机时，将数据送到哪一个进程的。</p>
<p>用快递类比就是：</p>
<ul>
<li>IP 地址类似收货地址。</li>
<li>端口号类似这个地址下的收件入口。</li>
</ul>
<p>通常，一台设备可以有：</p>
<table>
<thead>
<tr>
<th>服务</th>
<th>假设监听的端口</th>
</tr>
</thead>
<tbody>
<tr>
<td>MySQL 服务</td>
<td><code>3306</code></td>
</tr>
<tr>
<td>Java 服务 A</td>
<td><code>8080</code></td>
</tr>
<tr>
<td>Java 服务 B</td>
<td><code>9090</code></td>
</tr>
</tbody>
</table>
<p>那么<code>192.168.1.20:3306</code>和<code>192.168.1.20:8080</code>，虽然目标设备相同，但目标服务入口不同。</p>
<p>而端口号的范围也就是 TCP、UDP 等传输协议使用 16 位端口号，所以数值范围是 0~65535。因为 2&lt;sup&gt;16&lt;/sup&gt;=65536</p>
<p>而端口号和进程编号 PID 不是一回事~</p>
<p>PID 只是用于操作系统用来标识进程的，而一个进程可以拥有多个端口号~</p>
<blockquote>
<p>实际接收数据时，操作系统会根据地址、传输协议、端口等信息，找到对应的套接字，再交给应用程序处理。后面学习 Java Socket 时，会具体使用它。</p>
</blockquote>
<h3>1.3 协议</h3>
<p>协议的作用就相当于双方提前约定“<strong>数据怎样组织、怎样解释，以及通信过程中怎样行动</strong>”</p>
<p>就比如会这样约定：</p>
<table>
<thead>
<tr>
<th>约定</th>
<th>目的</th>
</tr>
</thead>
<tbody>
<tr>
<td>文字使用 UTF-8 编码</td>
<td>接收方知道怎样解码</td>
</tr>
<tr>
<td>消息中携带长度信息</td>
<td>接收方知道正文有多少字节</td>
</tr>
<tr>
<td>某种消息表示请求，另一种表示响应</td>
<td>接收方知道该怎样处理</td>
</tr>
</tbody>
</table>
<p>这里是在举自定义协议规则的例子，具体协议会有自己的设计。</p>
<p>所以，<strong>协议不仅规定数据格式，也会规定通信行为</strong>。</p>
<p>TCP、UDP、HTTP、MySQL 协议等，都在不同层次解决通信中的问题。</p>
<p>你写 JDBC 时，不需要自己把 SQL 拼成 MySQL 协议的数据包，是因为：</p>
<blockquote>
<p><strong>JDBC 驱动已经帮你实现了与 MySQL 服务通信的协议处理。</strong></p>
</blockquote>
<h3>1.4 五元组</h3>
<p>对于常见的 TCP、UDP 通信，这五项是：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>源 IP 地址</td>
<td>数据从哪个网络地址发出</td>
</tr>
<tr>
<td>源端口号</td>
<td>发送方使用的端口</td>
</tr>
<tr>
<td>目的 IP 地址</td>
<td>数据发往哪个网络地址</td>
</tr>
<tr>
<td>目的端口号</td>
<td>接收方使用的端口</td>
</tr>
<tr>
<td>传输层协议</td>
<td>使用 TCP、UDP 等哪种协议</td>
</tr>
</tbody>
</table>
<p>此处的“源”和“目的”，是相对于当前的数据传输方向来说的~</p>
<p><img src="./01.png" alt="" /></p>
<p>我这里继续拿 Java 访问 MySQL 举例：</p>
<p>假设</p>
<blockquote>
<ul>
<li>Java 程序所在设备的 IP 是 <code>192.168.1.10</code></li>
<li>MySQL 所在设备的 IP 是 <code>192.168.1.20</code></li>
<li>Java 这次连接使用本地端口 <code>50000</code></li>
<li>MySQL 服务监听 <code>3306</code></li>
<li>使用 TCP 通信</li>
</ul>
</blockquote>
<p>Java 向 MySQL 发送数据时：</p>
<table>
<thead>
<tr>
<th>五元组字段</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td>源 IP</td>
<td><code>192.168.1.10</code></td>
</tr>
<tr>
<td>源端口</td>
<td><code>50000</code></td>
</tr>
<tr>
<td>目的 IP</td>
<td><code>192.168.1.20</code></td>
</tr>
<tr>
<td>目的端口</td>
<td><code>3306</code></td>
</tr>
<tr>
<td>协议</td>
<td><code>TCP</code></td>
</tr>
</tbody>
</table>
<p>MySQL 回复时，源和目的会交换：</p>
<table>
<thead>
<tr>
<th>五元组字段</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td>源 IP</td>
<td><code>192.168.1.20</code></td>
</tr>
<tr>
<td>源端口</td>
<td><code>3306</code></td>
</tr>
<tr>
<td>目的 IP</td>
<td><code>192.168.1.10</code></td>
</tr>
<tr>
<td>目的端口</td>
<td><code>50000</code></td>
</tr>
<tr>
<td>协议</td>
<td><code>TCP</code></td>
</tr>
</tbody>
</table>
<p>所以显然，客户端并不永远是“源”，服务器也不永远是“目的”。谁正在发送，谁就是这个方向上的源。</p>
<p>假设很多客户端都在连接同一个 MySQL 服务。</p>
<p>它们的目的地址和目的端口可能都是<code>192.168.1.20:3306</code></p>
<p>服务器还需要区分：</p>
<blockquote>
<p>这份数据来自哪一条连接？回复应该发给谁？</p>
</blockquote>
<p>这就需要结合客户端的地址和端口。</p>
<p>因此，同一个服务端口可以接待很多连接，不是“一条连接就独占整个服务端口”。</p>
<p>在 Windows 中，可以通过：</p>
<pre><code>netstat -ano
</code></pre>
<p>三个选项分别表示：</p>
<table>
<thead>
<tr>
<th>选项</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>-a</code></td>
<td>显示活动连接及监听端口等信息</td>
</tr>
<tr>
<td><code>-n</code></td>
<td>使用数字显示地址和端口</td>
</tr>
<tr>
<td><code>-o</code></td>
<td>显示相关连接的进程编号 PID</td>
</tr>
</tbody>
</table>
<p><img src="./02.png" alt="" /></p>
<p>如果需要过滤（一般是通过端口号或进程 PID 过滤），可以使用 <code>netstat -ano | findstr</code> 过滤字符串</p>
<h2>2. 协议分层</h2>
<p>当前的问题是，刚刚上面的所有工作该如何分工。</p>
<p>解决方法是，<strong>把网络通信的工作划分为不同层次，各层负责自己的任务，并通过约定好的接口协作</strong>。</p>
<h3>2.1 同层之间遵守协议，相邻层之间使用服务</h3>
<p><img src="./03.png" alt="" /></p>
<p>先来看上图中的例子：</p>
<p>把打电话分成两层：</p>
<blockquote>
<p>语言层 负责双方用什么语言交流，怎样理解对方说的话</p>
<p>通信设备层 负责怎样通过设备传递声音</p>
</blockquote>
<p>这里其实有两套规则：</p>
<p><strong>语言层规则</strong></p>
<blockquote>
<p>双方需要理解汉语，才能明白：</p>
<p>“你明天几点到？”</p>
</blockquote>
<p><strong>通信设备层规则</strong></p>
<blockquote>
<p>电话系统需要按照相应规则，把声音传递给对方。</p>
<p>它负责让对方听到声音。</p>
</blockquote>
<p>其实也就是，第一层封装，第二层解耦</p>
<ol>
<li>上层协议不需要了解下层协议的细节~打电话的人会说话就行，不需要理解电话机原理</li>
<li>分层后，灵活的替换其中的某一层，对于整体的工作过程影响很小</li>
</ol>
<p>关于下方的两个改变，主要改变的是：上层怎样组织和理解表达内容</p>
<p>底下的电话系统仍然可传递声音。</p>
<p>所以分层的价值就是，<strong>在接口和服务约定保持兼容的情况下，一层的变化可以尽量减少对其他层的影响</strong>。</p>
<p>当然，实际网络通信比这个例子要复杂，一层的变化可以尽量减少对其他层的影响。</p>
<p><strong>其实分层是比较类似“面向接口编程”</strong></p>
<p>就类似<strong>调用方使用接口，提供方通过接口提供服务</strong>。</p>
<p>网络分层也有类似的思想：</p>
<ul>
<li>上层使用下层提供的服务</li>
<li>下层通过接口提供能力，隐藏自己的实现细节</li>
</ul>
<p><strong>同层之间遵守协议，相邻层之间使用服务</strong></p>
<p>此处要分清两个方向：</p>
<p><strong>同一设备内部</strong>，上层通过接口，使用下层的服务</p>
<p><strong>通信双方之间</strong>，双方对应层需要遵守相应的协议</p>
<h3>2.2 OSI 七层模型</h3>
<p>OSI（Open System Interconnection）开放系统互联</p>
<p>但 OSI 七层网络模型复杂也不实用，所以 OSI 七层模型都没有落地实现</p>
<p>实际组建网络时，只是以 OSI 七层模型设计中的部分分层，也就是以下 TCP/IP 五层（或四层）模型来实现</p>
<p><img src="./04.png" alt="" /></p>
<h3>2.3 TCP/IP 五层（或四层）模型</h3>
<p>五层或四层只是视角不同罢了~</p>
<p>TCP/IP 是一组协议的代名词，其中包含 TCP、UDP、IP 以及许多应用层协议</p>
<p><img src="./05.png" alt="" /></p>
<ol>
<li>首先是应用层。它关注的是应用程序之间，应该交换什么消息，又该如何理解这些信息。就比如 HTTP(网页等资源的请求与响应)、SMTP(电子邮件传输)、FTP(文件传输)等。在编写 Java 网络程序，主要就是在这一层组织业务消息，并通过 Socket 等接口使用下层的传输服务。当然，这一层你也可以理解为买家在拿到包裹后如何使用~</li>
<li>传输层关注的是<strong>通信两端的应用怎样交换数据（如 TCP 传输控制协议）</strong>。之前了解到的端口号就在此处发挥作用。字面意思就是，两个任意设备之间的通信，不考虑中间过程，只考虑起点和终点。就像是卖家在寄件时，只负责填发货地址和到货地址即可。</li>
<li>网络层，处理地址与跨网络转发，也就是负责地址管理和路由选择。两台主机之间可能隔着很多网络和路由器（就像是《无敌破坏王》中那样到网络中心一样），这一层就关心数据下一步该往哪里转发，对应的就是前面的 IP 地址。路由器会根据目的 IP 地址和路由表等信息，选择下一跳。并不要求发送方提前固定好整条完整路径。</li>
<li>数据链路层，则是负责设备之间的数据帧的传送和识别。这一层通常会处理数据帧的组织、链路上的寻址（如以太网中的 MAC 地址）、介质访问、差错检测等。主要就是关注当前链路上怎样把数据送到下一站。</li>
<li>物理层，真正传递信号。数据帧最终还需要通过实际介质传出去。而物理层就负责光/电信号的传递方式。这一层设计信号编号、传输介质、接口等问题。所以应用层所表达的业务消息，最终需要借助这些物理能力完成传递。</li>
</ol>
<p><strong>就相当于，我在淘宝上买了床刷子</strong></p>
<blockquote>
<p>我则关心床刷子买到之后，我要如何使用		-&gt;应用层</p>
<p>卖家只要关心收件人信息					-&gt;传输层</p>
<p>物流公司则关心包裹以怎样路径传输的		-&gt;网络层</p>
<p>快递小哥/货车司机考虑的是相邻节点		-&gt;数据链路层</p>
<p>国家在铺桥修路						-&gt;物理层</p>
</blockquote>
<p>写代码主要就是工作在应用层了~</p>
<p>而像主机、路由器、交换机就分别对应下方：</p>
<p>主机  -&gt;  工作过程涉及到  从物理层-&gt;应用层（通过应用程序满足网络通信的需求）</p>
<p>路由器-&gt; 工作过程涉及到  从物理层-&gt;网络层（组建局域网，进行网络）</p>
<p>交换机-&gt; 工作过程涉及到 从物理层-&gt;数据链路层（对路由器的接口的扩展，不需要考虑组网的问题）</p>
<p>此处说的是传统意义上的交换机和路由器，也称为二层交换机（工作在 TCP/IP 五层模型的下两层）、三层路由器（工作在 TCP/IP 五层模型的下三层）</p>
<p>上面的工作区间也是一个经典的面试题</p>
<h3>2.4 一则信息的全过程</h3>
<h4>2.4.1 数据发送</h4>
<p>假设我通过 QQ，发送 Hello 给好友：</p>
<ol>
<li>应用程序会先获取到用户输入，构造一个应用层的数据包。这个<strong>应用层数据包</strong>就会遵循应用层的协议（这部分往往是开发者自己定义的）。假设此处的协议格式是：</li>
</ol>
<blockquote>
<p>发送者的 qq 号，接收者的 qq，消息的时间，消息正文。（类似下图这种）</p>
<p>就以此为例：1234567,7654321,2026-10-04，12:00:00，Hello（当然实际是会加密的）</p>
</blockquote>
<p><img src="./06.png" alt="" /></p>
<p>注：上述所说的数据包往往是“结构化数据”。发送数据的时候，把结构化数据 -&gt; 字符串/二进制 bit 流（序列化）；接收数据的时候，字符串/bit 流 -&gt; 结构化数据（反序列化）</p>
<p><img src="./07.png" alt="" /></p>
<ol>
<li>应用程序调用传输层提供的接口（API 也就是 socket api），把数据交给<strong>传输层</strong>。传输层拿到数据之后，构造出“传输层数据包”。传输层的协议主要就是两个：TCP、UDP。得到数据包形式：TCP 数据包 = TCP 报头（header） + TCP 载荷（payload）。TCP 载荷就是应用层数据包。</li>
</ol>
<p><img src="./08.png" alt="" /></p>
<ol>
<li>传输层构造好数据之后，继续调用网络层的 api，把传输层的数据包交给<strong>网络层</strong>。网络层继续进行处理。网络层最主要的协议就是 IP 协议。IP 协议会继续对上述数据包进行加工 -&gt; 拼上 IP 报头~。得到的 IP 数据包形式就是： IP 数据包 = IP 报头 + IP 载荷（整个传输层的数据包）。其中 IP 报头包含的信息也有很多的，就比如源 IP 或 目的 IP。</li>
</ol>
<p>注：IP 报头还会记录传输层使用的是哪个协议。同样的，传输层报头中也会记录应用层使用的是哪个协议（准确的说是数据交给哪个应用程序）......</p>
<p><img src="./09.png" alt="" /></p>
<ol>
<li>IP 协议继续调用数据链路层的 api ，把 IP 数据包交给数据链路层。数据链路层中的核心协议就是“以太网”这个协议。以太网也会在网络层数据包的基础上进一步加工~</li>
</ol>
<p>到目前为止，数据还在当前主机上转悠，还没出门~</p>
<p><img src="./10.png" alt="" /></p>
<ol>
<li>以太网继续把这样的数据交给硬件设备（网卡）。网卡会按上述二进制数据最终以光信号/电信号/电磁波信号 传播出去~此时数据才算出门了~</li>
</ol>
<p><img src="./11.png" alt="" /></p>
<p>封装顺序就是 <strong>应用数据 -&gt; TCP 报文段 -&gt; IP 数据包 -&gt; 以太网帧 -&gt; 信号</strong></p>
<p>从上到下，数据都要进一步加工</p>
<p>解释一下：网络数据包/帧/报/段</p>
<p>段（segment）	TCP 用段</p>
<p>报（Datagram）	UDP用报</p>
<p>包（packet）		IP 用包</p>
<p>帧（frame）		数据链路层 以太网数据帧</p>
<p>但其实日常工作中，确实也不会明确区分这些概念~</p>
<h4>2.4.2 数据接收</h4>
<p>刚才是数据发送所进行的全过程，接下来是数据接收回来一步一步解封装</p>
<ol>
<li>数据到达接收方的网卡。网卡就会把光信号还原成二进制，随后把二进制数据交给上层数据链路层。</li>
</ol>
<p>补充一下：<strong>不同的网络连接方式，数据链路层使用的协议和帧格式也可能不同</strong></p>
<blockquote>
<p>网线/光线来的数据， 数据链路层 -&gt; 以太网</p>
<p>WiFi 来的数据，数据链路层 -&gt; IEEE 802.11 协议</p>
</blockquote>
<p><img src="./12.png" alt="" /></p>
<ol>
<li>数据链路层按照以太网协议进行解析。把报头和报尾取出来，剩下的载荷往上传递给网络层。以太网的帧头中就有专门的属性，描述了网络层使用哪个协议</li>
</ol>
<p><img src="./13.png" alt="" /></p>
<ol>
<li>网络层拿到这个数据之后，按照 IP 协议的格式解析：再把载荷数据交给传输层。IP 报头中也有专门的属性，描述了传输层使用哪个协议</li>
</ol>
<p><img src="./14.png" alt="" /></p>
<ol>
<li>传输层拿到数据之后，也是类似，按照 TCP 协议来解析。取出载荷，交给应用层。传输层的报头中，通过目的端口号，告知我们数据交给哪个应用程序</li>
</ol>
<p><img src="./15.png" alt="" /></p>
<p>QQ 应用程序解析应用层数据，拿到关键信息，展示到界面上，给出提示~不同的应用程序，自然使用不同的应用层协议来解析~</p>
<p><img src="./16.png" alt="" /></p>
<p>传输的中间过程中，也是涉及到封装分用的。</p>
<p>交换机 只需要封装分用到数据链路层即可</p>
<p><strong>交换机是工作在数据链路层（二层转发）</strong></p>
<p>主机的数据 -&gt; 交换机。交换机收到之后，物理层解析，然后数据链路层解析（没有网络层）~ 重新构造出新的以太网数据帧发送给下一个设备。</p>
<p>数据链路层中，得到以太网数据帧的帧头，信息就足以支持交换机进行下一步工作</p>
<p><strong>路由器是工作在网络层（三层转发）</strong></p>
<p>主机的数据 -&gt; 路由器。物理层，数据链路层，网络层解析（没有传输层），重新构造出新的网络数据包。构造出以太网数据帧，构造出二进制数据，再进行转发</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="计算机网络"/>
  </entry>
  <entry>
    <title>Java 并发进阶（三）：线程安全容器与并发集合</title>
    <link href="https://blog.syrena.cn/posts/javaee-multithreading-collections/javaee-multithreading-collections/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-multithreading-collections/javaee-multithreading-collections/</id>
    <published>2026-09-30T00:00:00.000Z</published>
    <updated>2026-09-30T00:00:00.000Z</updated>
    <summary>掌握多线程环境下ArrayList、队列与哈希表的线程安全方案及常用并发集合实现.</summary>
    <content type="html"><![CDATA[<h2>1.线程安全的集合类</h2>
<h3>1.1 多线程下使用 <code>ArrayList</code></h3>
<p>前面我们对 count++这样的共享变量使用 synchronized 来保护，如果面对的是这样：</p>
<pre><code>List&lt;String&gt; names = new ArrayList&lt;&gt;();
</code></pre>
<p>显然如果是在多线程，就不能直接调用 add()了。毕竟本身<code>ArrayList</code>也不负责同步。在多个线程同时访问下，并且至少有一个线程修改集合结构时，还需要另外的同步措施。否则就会出现结果不正确，遍历的时候也可能遇到并发修改问题。</p>
<p>下面是介绍三种在多线程下使用 ArrayList 方法</p>
<h4>自己使用同步机制（synchronized 或者 ReentrantLock）</h4>
<p>所有访问都遵守同一规则</p>
<pre><code>List&lt;String&gt; names = new ArrayList&lt;&gt;();
Object lock = new Object();

synchronized (lock) {
    names.add("张三");
}

synchronized (lock) {
    for (String name : names) {
        System.out.println(name);
    }
}
</code></pre>
<p>之所以读的时候也要用同一把锁，是因为如果一个线程正在遍历，另一个线程却同时修改，遍历过程就可能受到干扰。</p>
<p>所以此处的关键就是“同一个集合、同一把锁”。如果添加时锁<code>lockA</code>，遍历时锁 <code>lockB</code>，两个线程仍然可能同时操作集合；这两把锁不会替彼此协调。</p>
<p>自己加锁能用，但项目里每个访问位置都要守规矩，漏掉一处，前面的保护就可能失效~</p>
<h4><code>Collections.synchronizedList()</code>包装</h4>
<p>给原集合套一层同步包装</p>
<pre><code>List&lt;String&gt; names =
        Collections.synchronizedList(new ArrayList&lt;&gt;());

names.add("张三");
names.add("李四");
</code></pre>
<p>可以理解为，原来的<code>ArrayList</code>外面加了一层“门卫”。调用包装后的<code>name.add()</code>等方法时，由包装对象去负责同步。</p>
<p>创建时就包装的原因，是因为后续代码应该统一返回的<code>names</code>访问集合。如果有人绕过包装对象，直接去操作原始<code>ArrayList</code>，这层同步就管不到他了。</p>
<p>注意：遍历仍需要自己在包装对象上加锁</p>
<pre><code>synchronized (names) {
    for (String name : names) {
        System.out.println(name);
    }
}
</code></pre>
<p>但问题是，都说是<strong>线程安全的包装</strong>了，为什么遍历还要加锁？在学到这里的时候我也很困惑</p>
<p>主要是因为，遍历不是一次调用，耳熟“取得迭代器、检查有没有下一个，读取下一个......”的一连串动作。包装类能同步单次方法调用，却不能自动保证整个遍历期间没有其他线程插入修改。所以在官方文档中明确要求了并发遍历时，在返回的集合对象上手动同步~</p>
<p>这部分我就不再详细解释了，可以复制这部分问 AI 给出详细答案~</p>
<h4><code>CopyOnWriteArrayList</code>写入时复制</h4>
<p>这个方法叫写时复制</p>
<pre><code>List&lt;String&gt; names = new CopyOnWriteArrayList&lt;&gt;();

names.add("张三");
names.add("李四");
</code></pre>
<p><strong>举个例子：</strong></p>
<p>假设大家都在阅读一份名单，此时有人要修改名单：</p>
<ul>
<li><code>synchronized</code>的思路更像，修改和阅读要协调使用同一份名单。</li>
<li><code>CopyOnWriteArrayList</code>的思路则是，写入时制作一份<strong>新的底层数组</strong>，<strong>在新数组上完成修改</strong>，<strong>再让后续读取使用新版本</strong>；已经开始的遍历继续看它原来的版本。</li>
</ul>
<p>这样的目的是为了让读者遍历时不必因写入而打断当前遍历。代价则是每次写入都要复制底层数组，集合大、写入频繁时成本会高，官方文档也把它定位在遍历远多于修改的场景。</p>
<pre><code>// 用代码观察“旧遍历看到的旧版本”：
CopyOnWriteArrayList&lt;String&gt; names = new CopyOnWriteArrayList&lt;&gt;();
names.add("张三");

Iterator&lt;String&gt; iterator = names.iterator();
names.add("李四");
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}
System.out.println(names);
</code></pre>
<p>这段代码里，<code>iterator</code>是添加“李四”之前创建的，所以它遍历的是当时的快照，只打印“张三”；最后直接打印当前<code>names</code>，则能看到“张三”和“李四”。</p>
<p>注意“新写的数据不能第一时间读取到”，<strong>是已经创建的旧的迭代器不会看到后续修改</strong>；并不是说以后重新读取集合，也一直看不到新数据。</p>
<p><strong>再补充一个例子：</strong></p>
<p>假设当前底层数组是，<code>[1, 2, 3, 4]</code></p>
<p>此时一个线程正在读取这个数组，而另一个线程准备修改数据，例如把 <code>1</code> 修改成 <code>100</code>。</p>
<p><code>CopyOnWriteArrayList</code> 并不会直接在原来的数组上修改，而是先复制出一份新的数组，再在新数组上完成修改</p>
<p><img src="./11.png" alt="" /></p>
<p>也就是，复制和修改新数组的这段时间里，旧数组并不会发生变化。因此此时其他正在读取的线程仍然可以继续读取旧版本的数据，并不会因为写线程正在复制、修改而被阻塞。</p>
<p>等新的数组复制、修改完成之后，<code>CopyOnWriteArrayList</code>才会把自己内部保存的<strong>数组引用切换到新数组</strong>。</p>
<p>实际实现中，<code>CopyOnWriteArrayList</code>保存底层数组的引用具有相应的<strong>内存可见性保证</strong>，因此新数组准备完成以后再发布出去。这样读线程看到的要么是完整的旧版本，要么是完整的新版本，而不是一个“修改了一半”的中间状态。</p>
<p>不过，这里要区分一下两种“读取”：</p>
<ul>
<li><strong>普通读取在新数组发布以后，后续就可以读取到新数据</strong></li>
<li><strong>但是已经创建好的迭代器比较特殊，他会一直持有创建那一刻的数组快照~</strong></li>
</ul>
<p>很明显，上面的方案也有<strong>缺点</strong>：</p>
<ol>
<li>数组特别大时，非常低效</li>
<li>如果多个线程同时修改，也容易出问题</li>
</ol>
<p>只能适用于特定场景，比如服务器进行重新加载配置的时候......</p>
<p>我导出了一份对比：</p>
<table>
<thead>
<tr>
<th><strong>方式</strong></th>
<th><strong>核心做法</strong></th>
<th><strong>注意</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>自己加锁</td>
<td>所有相关访问使用同一把锁</td>
<td>不能漏掉访问位置</td>
</tr>
<tr>
<td><code>synchronizedList</code></td>
<td>包装常用集合方法</td>
<td>并发遍历仍需手动同步</td>
</tr>
<tr>
<td><code>CopyOnWriteArrayList</code></td>
<td>修改时复制底层数组</td>
<td>适合读多写少；旧迭代器只看旧快照</td>
</tr>
</tbody>
</table>
<p>综上，集合的“线程安全”，看<code>add()</code>会不会冲突，还要看遍历、复合操作，以及读写同时发生时应当看到哪个版本。</p>
<h3>1.2 多线程下使用队列</h3>
<p><strong>（该小节利用 AI 进行整理）</strong></p>
<p>现在把共享资源换成队列</p>
<p><strong>举个例子：</strong></p>
<p>假设一家奶茶店，前台负责接单，后厨负责制作。前台接到订单以后，会先把订单放到等待区，后厨再从等待区取出订单进行处理。</p>
<p>这里的等待区，就可以理解为一个队列：</p>
<ul>
<li><strong>生产者</strong>：负责往队列里放入任务，比如前台提交订单。</li>
<li><strong>消费者</strong>：负责从队列里取出任务，比如后厨取走订单。</li>
</ul>
<p>但问题是，如果没有订单，后厨怎么办？如果等待区已经满了，前台还能继续放吗？</p>
<p>这就需要用到<strong>阻塞队列</strong>，Java 中对应的接口是：</p>
<pre><code>BlockingQueue&lt;E&gt;
</code></pre>
<p>不仅负责线程安全地存取元素，还提供了条件不满足时等待的操作：</p>
<pre><code>queue.put(task);    // 放入元素，没有空位时等待
Task task = queue.take(); // 取出元素，没有元素时等待
</code></pre>
<p>注意，<strong>阻塞的是调用方法的那个线程</strong>。比如消费者在等订单，生产者仍然可以提交订单；有了订单以后，消费者才有机会继续执行。</p>
<p>不过，阻塞队列也不是所有方法都会等待，这里可以先做一个区分：</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>方法</th>
<th>暂时无法完成时</th>
</tr>
</thead>
<tbody>
<tr>
<td>放入元素</td>
<td><code>put(e)</code></td>
<td>等待空位</td>
</tr>
<tr>
<td>取出元素</td>
<td><code>take()</code></td>
<td>等待元素</td>
</tr>
<tr>
<td>放入元素</td>
<td><code>offer(e)</code></td>
<td>没有空位就返回 <code>false</code></td>
</tr>
<tr>
<td>取出元素</td>
<td><code>poll()</code></td>
<td>没有元素就返回 <code>null</code></td>
</tr>
</tbody>
</table>
<p>所以具体是等待还是直接返回，要看调用的方法，而不能只看它是不是“阻塞队列”~</p>
<p>下面介绍三种队列。</p>
<h4><code>ArrayBlockingQueue</code></h4>
<p>这个名字可以拆开理解：</p>
<p><code>Array</code>：底层使用数组。</p>
<p><code>BlockingQueue</code>：支持阻塞操作的队列。</p>
<pre><code>BlockingQueue&lt;String&gt; orders =
        new ArrayBlockingQueue&lt;&gt;(2);
</code></pre>
<p>这里的 <code>2</code> 表示，<strong>队列里最多存放两个元素</strong>。</p>
<p>它属于<strong>有界队列</strong>，创建时必须指定容量，而且创建以后容量就固定了，不会像 <code>ArrayList</code> 一样自动扩容。元素按照<strong>先进先出</strong>的顺序取出。</p>
<p><strong>举个例子：</strong></p>
<p>假设奶茶店的等待区只能放两张订单：</p>
<pre><code>orders.put("订单1");
orders.put("订单2");

boolean success = orders.offer("订单3");
System.out.println(success); // false
</code></pre>
<p>前两次 <code>put()</code> 可以成功，因为队列还有空位。</p>
<p>但第三张订单放不进去，所以这里的 <code>offer()</code> 返回 <code>false</code>。之所以使用 <code>offer()</code>，是为了观察“队列满了”的结果，同时让当前线程还能继续往下执行。</p>
<p>如果改成：</p>
<pre><code>orders.put("订单3");
</code></pre>
<p>当前线程就会等待，直到其他线程取走一个元素，腾出空位。</p>
<p><strong>再补充一个例子：</strong></p>
<p>接着前面的队列，先取走一张订单，再放入第三张：</p>
<pre><code>String firstOrder = orders.take();
System.out.println(firstOrder); // 订单1

orders.put("订单3");

System.out.println(orders.take()); // 订单2
System.out.println(orders.take()); // 订单3
</code></pre>
<p>之所以先取出“订单1”，是因为它最早进入队列。取出以后，队列空出一个位置，“订单3”才能放进去。</p>
<p>不过，这里要区分一下：</p>
<p><strong>队列容量限制的是队列中等待的元素数量，不是线程数量，也不是所有未完成任务的数量。</strong></p>
<p>比如后厨取走“订单1”以后，虽然还没制作完成，但这张订单已经离开队列了，因此队列可以接收新的订单。</p>
<p>另外，上面的 <code>put()</code>、<code>take()</code> 需要处理或者声明 <code>InterruptedException</code>。练习时可以把代码放在声明了该异常的 <code>main()</code> 中：</p>
<pre><code>public static void main(String[] args)
        throws InterruptedException {
    // 示例代码
}
</code></pre>
<p>因为等待期间可能被中断，所以这两个方法要求我们考虑这种情况。</p>
<h4><code>LinkedBlockingQueue</code></h4>
<p>和前面的数组队列相比，<code>LinkedBlockingQueue</code> 底层使用的是<strong>链表节点</strong>，同样按照<strong>先进先出</strong>的顺序取出元素。</p>
<p>它也可以指定容量：</p>
<pre><code>BlockingQueue&lt;String&gt; orders =
        new LinkedBlockingQueue&lt;&gt;(100);
</code></pre>
<p>这里表示，队列最多存放一百张订单。达到容量以后，再调用 <code>put()</code>，生产者就需要等待。</p>
<p>不过，它还可以这样创建：</p>
<pre><code>BlockingQueue&lt;String&gt; orders =
        new LinkedBlockingQueue&lt;&gt;();
</code></pre>
<p>没有传入容量，那是不是就能无限存放元素了？</p>
<p>显然不是。不指定容量时，默认容量是 <code>Integer.MAX_VALUE</code>，也就是 <code>2147483647</code>。</p>
<p>所以不能简单理解为“链表可以不断增加节点，队列就永远不会满”。</p>
<p><strong>举个例子：</strong></p>
<p>假设前台每秒接一百张订单，但后厨每秒只能完成十张。如果一直按照这个速度运行，等待区中的订单就会越积越多。</p>
<p>没有设置合适的容量上限时，程序可能持续接收任务，直到积压的数据占用大量内存。</p>
<p>因此，创建时指定容量的意义，不只是决定能放多少元素，也是为了让生产者在积压达到上限时先等待，给消费者处理任务的时间。</p>
<p>它的基本使用方式和前面一样：</p>
<pre><code>BlockingQueue&lt;String&gt; orders =
        new LinkedBlockingQueue&lt;&gt;(2);

orders.put("订单1");
orders.put("订单2");

System.out.println(orders.take()); // 订单1
System.out.println(orders.take()); // 订单2
</code></pre>
<p>可以发现，换成链表实现以后，生产者和消费者仍然通过 <code>put()</code>、<code>take()</code> 配合，不需要重新写一套等待与通知逻辑~</p>
<h4><code>PriorityBlockingQueue</code></h4>
<p><strong>按优先级取出的阻塞队列</strong></p>
<p>前面两种队列都是先进先出，但有些场景并不适合只按进入顺序处理。</p>
<p><strong>举个例子：</strong></p>
<p>医院接诊时，普通患者虽然来得早，但后来送来的急救患者，可能需要优先处理。</p>
<p>这时就需要：</p>
<pre><code>PriorityBlockingQueue
</code></pre>
<p>它会根据<strong>元素的自然顺序</strong>或者我们提供的<strong>比较器</strong>，决定下一次取出哪个元素。</p>
<p>先用整数观察：</p>
<pre><code>BlockingQueue&lt;Integer&gt; queue =
        new PriorityBlockingQueue&lt;&gt;();

queue.put(30);
queue.put(10);
queue.put(20);

System.out.println(queue.take()); // 10
System.out.println(queue.take()); // 20
System.out.println(queue.take()); // 30
</code></pre>
<p>明明 <code>30</code> 先放进去，为什么却先取出 <code>10</code>？</p>
<p>因为对于 <code>Integer</code>，默认的自然顺序是从小到大。因此这里先取出最小的元素，而不是最早进入队列的元素。</p>
<p>所以，“优先级高”并不一定表示数字大，具体含义要由排序规则决定。</p>
<p>如果希望数字越大越先取出，可以提供一个比较器：</p>
<pre><code>BlockingQueue&lt;Integer&gt; queue =
        new PriorityBlockingQueue&lt;&gt;(
                10,
                new Comparator&lt;Integer&gt;() {
                    @Override
                    public int compare(Integer a, Integer b) {
                        return Integer.compare(b, a);
                    }
                }
        );
</code></pre>
<p>这里把比较顺序反过来，因此放入 <code>30、10、20</code> 后，取出顺序就是 <code>30、20、10</code>。</p>
<p>不过，构造方法中的 <code>10</code> 要特别注意：</p>
<p><strong>它表示初始容量，不是最多只能存放十个元素。</strong></p>
<p><code>PriorityBlockingQueue</code> 是逻辑上的<strong>无界队列</strong>，可以扩容。放入元素时，不会因为达到某个固定容量上限而等待，但仍然受到实际内存的限制；队列为空时，调用 <code>take()</code> 则会等待。</p>
<p><strong>再补充一个容易混淆的地方：</strong></p>
<p>假设消费者已经取走一个普通任务，正在执行。此时又放入了一个紧急任务，队列并不会把正在执行的任务强行中断。</p>
<p>优先级影响的是<strong>下一次从队列中取出哪个任务</strong>，并不代表可以抢占已经开始执行的任务。</p>
<p>另外，观察优先顺序时，应该看连续调用 <code>take()</code> 或 <code>poll()</code> 的结果。直接使用 <code>for-each</code> 遍历，<strong>不保证按照优先级顺序输出</strong>；相同优先级的元素，也不保证按照进入顺序取出。</p>
<p>我整理了一份对比：</p>
<table>
<thead>
<tr>
<th>队列</th>
<th>底层结构</th>
<th>容量特点</th>
<th>取出顺序</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ArrayBlockingQueue</code></td>
<td>数组</td>
<td>必须指定容量，创建后固定</td>
<td>先进先出</td>
</tr>
<tr>
<td><code>LinkedBlockingQueue</code></td>
<td>链表</td>
<td>可以指定容量，默认是 <code>Integer.MAX_VALUE</code></td>
<td>先进先出</td>
</tr>
<tr>
<td><code>PriorityBlockingQueue</code></td>
<td>堆</td>
<td>逻辑上无界，构造参数是初始容量</td>
<td>按自然顺序或比较器决定</td>
</tr>
</tbody>
</table>
<h3>1.3 多线程下使用哈希表</h3>
<p>首先需要知道 HashMap 本身不是线程安全的</p>
<p>而在多线程环境下使用哈希表可以使用：</p>
<ul>
<li>Hashtable</li>
<li>ConcurrentHashMap</li>
</ul>
<p>我们已经讨论过多线程下使用数组、队列，那么现在就轮到哈希表了。</p>
<p>首先，我给一个使用环境，比如我们想统计每篇博客的访问次数：</p>
<pre><code>map.put("Java多线程", 100);
map.put("MySQL事务", 200);
</code></pre>
<p>如果是只有一个线程操作，问题不大。但如果是在服务器里，多个用户同时访问，就有可能多个线程同时操作这张哈希表。</p>
<p>这部分我打算主要介绍这两个类：<code>Hashtable</code>和<code>ConcurrentHashMap</code></p>
<h4>HashMap 不负责线程同步</h4>
<p>先来回忆一下哈希表，往 <code>HashMap</code> 里放一个键值对，不只是简单地给变量赋值，就像这样<code>map.put("张三", 90)</code></p>
<p>内部还需要有这几步：</p>
<ol>
<li>根据 key 的哈希值，确定放在哪个桶</li>
<li>查看桶里有没有已有的元素</li>
<li>判断是新增键值对，还是替换已有值</li>
<li>必要时调整结构、更新数量，甚至扩容</li>
</ol>
<p>如果是两个线程同时修改这些内部数据，而且还没有同步措施，就可能相互干扰~</p>
<p><strong>所以必须先强调：<code>HashMap</code> 本身不是线程安全的。</strong></p>
<p>多个线程共享它，并且存在并发修改时，需要额外的同步措施，Java 文档也明确了这一点。</p>
<p>最直接的办法是，像前面提到的<code>ArrayList</code>一样</p>
<pre><code>Map&lt;String, Integer&gt; scores = new HashMap&lt;&gt;();
Object locker = new Object();

synchronized (locker) {
    scores.put("张三", 90);
}

synchronized (locker) {
    Integer score = scores.get("张三");
}
</code></pre>
<p>注意，<strong>需要协调的访问，都遵循同一把锁的规则</strong>。由于每次操作都需要自己写同步代码，所以 Java 就提供了接下来要说到的<strong>现成的同步实现</strong>。</p>
<h4><code>Hashtable</code>把关键方法加锁</h4>
<p>使用的方式还是比较熟悉的</p>
<pre><code>Map&lt;String, Integer&gt; scores = new Hashtable&lt;&gt;();
scores.put("张三", 90);
scores.put("李四", 85);
System.out.println(scores.get("张三"));
</code></pre>
<p>其基本思路可以理解为，给<code>get()</code>、<code>put()</code>、<code>remove()</code>等关键方法加上<code>synchronized</code>.</p>
<p>这些实例方法加锁，<strong>锁的是当前 Hashtable 对象</strong>。</p>
<p>所以多线程调用同一个对象的这些方法，竞争的是同一把锁。<code>Hashtable</code> 是一个提供同步的 Map 实现。</p>
<p><img src="./12.png" alt="" /></p>
<p>当你点入标准库中的 Hashtable 你会发现，它的大部分方法就是在前面加上了<code>synchronized</code>，没错就是硬上锁。所以它的结构就如同下面这样：</p>
<p><img src="./13.png" alt="" /></p>
<p>一个<code>Hashtable</code>只有一把锁，两个线程访问<code>Hashtable</code>中的任意数据都会出现锁竞争。</p>
<p>另外，一旦触发扩容，该线程就会完成整个扩容过程，这个过程涉及到大量的元素拷贝，效率会非常低。</p>
<p>所以就引出了<code>ConcurrentHashMap</code></p>
<h4><code>ConcurrentHashMap</code>缩小需要互斥的范围</h4>
<p>使用方法还是类似：</p>
<pre><code>ConcurrentHashMap&lt;String, Integer&gt; scores =
        new ConcurrentHashMap&lt;&gt;();

scores.put("张三", 90);
scores.put("李四", 85);
System.out.println(scores.get("张三"));
</code></pre>
<p>它在设计时就考虑了多线程并发访问。</p>
<p>由于上面的<code>Hashtable</code>采用的是整体置锁，因此<code>ConcurrentHashMap</code>采用<strong>另外的方式</strong>进行置锁以保证减少锁竞争问题，达到效率的提高。</p>
<p>我们知道，在哈希表中，底层那个数组中，每一个位置都可以视为桶，也就是说一个哈希表有许多桶组成（如果忘记这点 可以看看 JavaSE 篇中的内容）</p>
<p><code>ConcurrentHashMap</code><strong>要解决的就是，在我们操作哈希表时，减少其中的锁竞争。</strong></p>
<p>我们知道，针对不一样的 key 可能会落入不同的哈希桶中，当然也有可能落入同一个桶中。</p>
<p>假设现在：</p>
<ul>
<li>A 修改桶 0 中的数据。</li>
<li>B 修改桶 1 中的数据。</li>
</ul>
<p><strong>既然它们修改的主要结构互不相同，就可以尽量允许它们并发执行。</strong></p>
<ul>
<li>C 修改桶 2 中的王五。</li>
<li>D 修改桶 2 中的赵六。</li>
</ul>
<p><strong>虽然 key 不同，但它们操作的是同一个桶，就可能需要互斥。</strong></p>
<p>于是乎，在 Java5~Java7 阶段<code>ConcurrentHashMap</code>使用了分段锁的思想，缩小了锁保护的范围，降低了不同操作争抢同一把锁的概率。</p>
<blockquote>
<p>它主要协调的是不同的桶之间的关系，你不能记成 key 不同，就一定不会产生锁竞争；更准确的是，<strong>落在不同的桶里的修改，通常更有机会并发执行。</strong></p>
</blockquote>
<p>再说明一下分段锁：</p>
<blockquote>
<p>也就是说在 Java5~Java7 阶段，一开始是多个桶放一把锁。用官方的描述是“多个桶被划分到一个 <code>Segment</code> 中，一个 <code>Segment</code> 对应一把锁。”</p>
</blockquote>
<p>这还是比较好理解的，就比如<strong>锁 1 被分配在桶 1,2,3,4,5,6</strong> ，然后<strong>锁 2 被分配在桶 7,8,9,10,11</strong>......以此类推</p>
<p>OpenJDK 7 的源码注释直接写明，哈希表被划分为多个 <code>Segment</code>，每个 <code>Segment</code> 自己又是一个哈希表。（具体参考论述可以追踪到<a href="https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/share/classes/java/util/concurrent/ConcurrentHashMap.java">OpenJDK7</a>）</p>
<p>但上述问题是，锁粒度还是有些大，就比如桶 1 与桶 2 之间还会有可能存在锁竞争。</p>
<p>于是在 Java8<code>ConcurrentHashMap</code> 做了一次非常大的重构，直接取消了 <code>Segment</code> 作为主要并发结构。而变成<code>Node&lt;K,V&gt;[] table</code>，结构更像普通的<code>HashMap</code>。也就是将锁粒度缩小到桶级别，并将桶的头节点作为<code>synchronized</code>的锁对象。</p>
<p><img src="./14.png" alt="" /></p>
<p>在 Java8 中是能不用锁尽量不用锁。</p>
<p>源码中还专门提及过“向空桶插入第一个节点时直接通过 CAS 完成。”</p>
<p>对了，读操作<code>get()</code>没有加锁，配合<code>volatile</code>保证了可见性。</p>
<p>另外再强调一个地方，就是<strong>关于扩容</strong>部分的优化：</p>
<blockquote>
<p>对于 <code>ConcurrentHashMap</code>，可以这样理解，<strong>把迁移工作拆成若干段，允许多个线程领取不同的部分，协作完成迁移。</strong></p>
</blockquote>
<p>扩容过程中，新旧数组会暂时同时存在。</p>
<p>但并不是每次查询都把两张表完整查一遍。</p>
<blockquote>
<p>关键在于，<strong>某个旧桶迁移完成以后，会留下一个转发节点，告诉后续操作去新表找。</strong></p>
</blockquote>
<p><strong>关于迁移要注意这几个点</strong></p>
<ul>
<li><strong>并不是每个访问线程都会参与搬迁。</strong> 例如，<code>get()</code> 可以根据转发节点去新表查找，不负责帮助迁移。</li>
<li><strong>并不是每个线程只搬固定几个元素就结束。</strong> 迁移按桶区间分配，参与线程可能完成多批工作。</li>
<li><strong>扩容期间，插入也不是一律只操作新表。</strong> 尚未迁移的桶仍可能在旧表处理；遇到已迁移的桶，才转向新表。</li>
</ul>
<p>全部迁移完成以后，当前表切换为新表。旧数组在不再被引用后，由垃圾回收处理。</p>
<p>（重点）<code>ConcurrentHashMap</code>对<code>HashMap</code>的核心优化点：</p>
<ol>
<li>把锁整个表 -&gt; 锁桶，以及读操作不加锁</li>
<li>充分利用 CAS 特性，针对 size 属性通过 CAS 更新，避免出现重量级锁</li>
<li>优化<strong>扩容方式</strong>以及<strong>化整为零</strong>（把 锁整个表 → 锁桶 就是锁粒度的化整为零）</li>
</ol>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 并发进阶（二）：CAS、原子类与 JUC 工具</title>
    <link href="https://blog.syrena.cn/posts/javaee-multithreading-juc/javaee-multithreading-juc/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-multithreading-juc/javaee-multithreading-juc/</id>
    <published>2026-09-29T00:00:00.000Z</published>
    <updated>2026-09-29T00:00:00.000Z</updated>
    <summary>理解CAS、原子类与ABA问题，掌握Callable、ReentrantLock、Semaphore和CountDownLatch等JUC并发工具.</summary>
    <content type="html"><![CDATA[<h2>1.CAS</h2>
<p>CAS 前面其实在说<code>synchronized</code>就已经就接触到这个词了。</p>
<p>但那会只是简单的解释了一下：</p>
<blockquote>
<p>线程可以通过 CAS 去尝试修改锁状态，如果成功就说明拿到锁；失败就说明有人竞争，再继续自旋；</p>
</blockquote>
<p>这里我再具体说明一下：CAS 全称 Compare and swap，字面意思“比较并交换”。</p>
<p>CAS 操作可以拆成三个值，有如下：</p>
<blockquote>
<p>V：内存中的原始值</p>
<p>A：线程认为它是旧值，也叫预期值</p>
<p>B：线程想写入的新值</p>
</blockquote>
<p>而 CAS 要做的事情就是，只有 V 任然等于 A，才把 V 改成 B；否则不修改，并告诉线程这次失败了。</p>
<blockquote>
<p>这段写的代码不是原子的，真实的 CAS 是一个原子的硬件指令完成的，这个伪代码只是辅助理解 CAS 的工作流程</p>
</blockquote>
<pre><code>boolean CAS(address, expectValue, swapValue) {
    if (&amp;address == expectValue) {
        &amp;address = swapValue;
        return true;
    }
    return false;
}
</code></pre>
<p>比如共享变量最初是 <code>10</code>。线程想执行 <code>CAS(原始值 10, 预期值 10, 新值 11)</code>：</p>
<ul>
<li>如果执行 CAS 时变量仍是 <code>10</code>，修改成功，变量变成 <code>11</code>。</li>
<li>如果另一个线程已经把预期值改成 <code>11</code>，预期值对不上，修改失败，变量保持原值。</li>
</ul>
<p>还可以这么理解，你正在修改在线文档，你开始编辑时看到的是第 10 版，提交时系统先确认他是否仍为第 10 版；如果别人已经提交了第 11 版，就不能直接用你的旧内容覆盖它。</p>
<p>实际代码中的 if 不具备原子性，上面的已经说过只是伪代码辅助理解 CAS，实际的 if 会可能存在之前说的内存可见性问题，这里我就不再展开解释了~</p>
<p><strong>CAS</strong> 其实和前面的<strong>乐观策略</strong>是有一定联系：</p>
<blockquote>
<p>CAS 会先按照刚才读到的值长尝试修改，如果期间没人改它，那么就会成功；如果有人先改了，它再重新取，重新计算。</p>
</blockquote>
<p>也就是失败的 CAS 不会接着完成，而是会在失败后再重试；</p>
<h3>1.1 CAS 实现原子类</h3>
<p><img src="./08.png" alt="" /></p>
<p>前面的典型操作，count++线程不安全，就需要加锁来解决问题。但是又认为，加锁效率比较低，于是就可以通过 CAS 来实现 count++，这样能确保性能，又同时保证线程安全~</p>
<p>标准库中提供了<code>java.util.concurrent.atomic</code>包，里面的类都是基于这种方式来实现的。典型的就是<code>AtomicInteger</code>类，其中的<code>getAndIncrement</code>相当于 i++操作；</p>
<p><img src="./09.png" alt="" /></p>
<p>实际只想给计数器加 1 时，直接使用原子类即可：</p>
<pre><code>AtomicInteger count = new AtomicInteger(0);
// 相当于 i++
int oldValue = count.getAndIncrement();
</code></pre>
<p>伪代码实现：</p>
<pre><code>// 原理伪代码，不是可直接运行的 AtomicInteger 源码
class AtomicInteger {
    private int value;

    public int getAndIncrement() {
        int oldValue = value;

        while (CAS(value, oldValue, oldValue + 1) != true) {
            oldValue = value;
        }

        return oldValue;
    }
}
</code></pre>
<p>假设 value 一开始是 0，我们按顺序看：</p>
<ol>
<li><code>oldValue = value</code>：先记下当前值 0。因为稍后要根据它计算新值，还要把这个旧值返回给调用者。</li>
<li><code>CAS(value, oldValue, oldValue + 1)</code>：尝试确认共享值是否仍为 <code>oldValue</code>。如果仍是 0，就原子地改成 1，本次操作成功。</li>
<li>如果 CAS 失败，就执行 <code>oldValue = value</code>，重新读取共享值。因为其他线程可能已经把它改了，原来的 oldValue + 1 也就不再是这次应该写入的结果。</li>
<li>循环直到 CAS 成功，最后 <code>return oldValue</code>。返回的是这次成功加一之前的值，不是加一后的值。<br />
比如 A、B 都读到 0，A 先通过 CAS 把值改成 1。B 再拿“预期是 0”去尝试，就会失败。B 重新读到 1，下一轮尝试把 1 改成 2。如果成功，B 返回 1；最终共享值是 2。</li>
</ol>
<blockquote>
<p><strong>注意</strong>：代码里的 <code>CAS(value, …)</code> 是为了说明原理。普通 Java 方法接收一个 <code>int</code> 参数，不能仅靠这个参数原子地修改外部字段；这段也不能当作 JDK <code>AtomicInteger</code> 的实际源码来运行。写程序时直接使用标准库的 <code>AtomicInteger.getAndIncrement()</code> 即可。</p>
</blockquote>
<h3>1.2 用 CAS 实现自旋锁</h3>
<p>前面学习过的自旋锁是指，线程暂时拿不到锁，就先不进入等待状态，而是在原地反复尝试。</p>
<p><strong>但问题是：两个线程都看见“锁没人持有”，又怎能保证只有一个线程拿到</strong></p>
<p>假设用 owner 记录锁的持有者：</p>
<ul>
<li><code>owner == null</code>：目前没人持有</li>
<li><code>owner == 当前线程</code>：当前线程拿到了锁</li>
</ul>
<p>线程 A 和 B 同时来时，都想把<code>owner</code>从<code>null</code>改成自己。这里不然能用普通的“先判断，再赋值”，因为两人可能都判断为<code>null</code>。于是用 CAS：</p>
<pre><code>owner.compareAndSet(null, 当前线程)
</code></pre>
<p>意思是，只有<code>owner</code>此刻仍然是<code>null</code>，才原子地把它改成当前线程。</p>
<p>假设 A 先成功，<code>owner</code> 就变成 A；B 再尝试时，发现它已经不是 <code>null</code>，因此失败。B 可以继续循环，等待 A 释放后再试。这就是 CAS 实现自旋锁的核心。Java 的 <code>AtomicReference.compareAndSet()</code> 正好提供对对象引用的这种操作。</p>
<p>下面是一段伪代码：</p>
<pre><code>public class SpinLock {
    private Thread owner = null;

    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有。
        // 如果这个锁已经被别的线程持有，那么就自旋等待。
        // 如果这个锁没有被别的线程持有，那么就把 owner 设置为当前尝试加锁的线程。
        while(!CAS(this.owner, null, Thread.currentThread())){

        }
    }

    public void unlock (){
        this.owner = null;
    }
}
</code></pre>
<p>加锁操作中，就需要判定，锁是否被人占用；如果未被人占用，就把当前线程的引用设置到 owner 中；如果已经被人占用，就自旋等待。</p>
<p>由于循环体是空着的，整个循环速度非常快（忙等），但是一旦其他线程释放了锁，此时此刻线程就能第一时间拿到这里的锁。</p>
<h3>1.3 CAS 的 ABA 问题</h3>
<p>刚刚一直在判断，<strong>“当前值和我先前读到的值，是否相等”</strong></p>
<p>但是“相等”<strong>并不能证明</strong>“中间从未被别人修改过”</p>
<p>这就像是你去买手机买电脑可能买到“翻新机、二手机”一样，经过翻新之后，机器外观看起来就和新机完全一样，实际上里面早就是不一样了~</p>
<p>CAS 中的 ABA 问题其实大部分情况下，即使出现了 ABA，最终的程序其实一般问题也不大。只有一些极端场景，ABA 问题才会产生一些严重 BUG</p>
<p>就比如取银行取钱：</p>
<p>假设账户里原本有 100 元，线程 1 和线程 2 都读取到了这个值，并且都准备把余额从 100 修改成 50。正常情况下，线程 1 先执行成功，把余额改成 50；等线程 2 再执行时，发现当前值已经不是自己之前读取的 100 了，所以 CAS 失败，这是我们期望的结果。</p>
<p><strong>但是如果在线程 2 执行之前，又有其他操作把账户余额从 50 重新改回了 100</strong>，那么线程 2 再进行 CAS 时，会发现：</p>
<blockquote>
<p>之前读取的值：100</p>
<p>当前的值：100</p>
</blockquote>
<p>从数值上看没有发生变化，于是 CAS 会认为这个数据没有被修改过，并继续执行扣款。</p>
<p>但实际上余额已经经历了：</p>
<blockquote>
<p>100 → 50 → 100</p>
</blockquote>
<h4>ABA 问题解决</h4>
<p>ABA 问题产生的原因，是 CAS 只会比较“当前值”和“之前读取到的值”是否相同，却无法判断这个值在中间有没有被修改过。</p>
<p>所以要解决 ABA 问题的常见思路，就是在原来的数据之外，再额外增加一个<strong>版本号</strong>。每次读取数据时，不仅要读取当前值，还要记录他对应的版本号；真正执行 CAS 时，也要同时比较数据和版本号。</p>
<blockquote>
<p>比如账户余额一开始是 100 元，版本号是 1。线程 1 和线程 2 都读取到了“余额 100，版本号 1”。</p>
<p>随后线程 1 先执行扣款，把余额从 100 修改成 50，同时版本号从 1 增加到 2。之后又有人向这个账户转入 50 元，余额重新变回 100，但版本号会继续增加到 3。</p>
<p>这时候线程 2 再执行 CAS。虽然它之前读取到的余额是 100，而现在的余额也正好是 100，看起来数据没有发生变化，但线程 2 保存的版本号还是 1，而当前版本号已经变成了 3</p>
<p>因此版本号比较失败，这次 CAS 也就不会成功。</p>
</blockquote>
<p>这样的话，即使数据经历了“100 → 50 → 100”，最终又回到了原来的值，我们仍然可以通过版本号发现，<strong>这个数据中间其实已经被修改过了</strong>。</p>
<p>所以，解决 ABA 问题的核心可以简单理解为：</p>
<blockquote>
<p><strong>普通 CAS 只比较值，而解决 ABA 问题后，需要同时比较“值 + 版本号”。</strong></p>
</blockquote>
<p>Java 中提供了<code>AtomicStampedReference&lt;E&gt;</code>来实现这种思路，其中保存的数据可以理解为实际值，而<code>stamp</code>就相当于这个值对应的版本号。每次成功修改数据时，同时更新版本号，这样就可以判断数据在这期间是否发生过变化~</p>
<p>对应出现的相关面试题可能就会这样出：</p>
<ol>
<li>讲解一下你自己理解的 CAS 机制</li>
</ol>
<blockquote>
<p>全称 Compare and Swap，即“比较并交换”。相当于通过一个原子的操作，同时完成“读取内存，比较是否相等，修改内存”这三个步骤，本质上需要 CPU 指令的支撑</p>
</blockquote>
<ol>
<li>ABA 问题如何解决</li>
</ol>
<blockquote>
<p>给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时，也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致，就真正执行修改操作，并让版本号自增；如果发现当前版本号比之前读到的版本号大，就认为操作失败</p>
</blockquote>
<h2>2.JUC 的常见类</h2>
<p>JUC 是指<code>java.util.concurrent</code>及其相关包。其实前面用过的<code>AtomiciInteger</code>就属于 JUC；我就从里面两个主要的类讲一讲~</p>
<h3>2.1 Callable 接口</h3>
<p>在前面创建线程的时候，经常是将 Runnable 实例出一个 task 交给线程去执行。</p>
<pre><code>Runnable task = new Runnable() {
    @Override
    public void run() {
        // 执行任务
    }
};

Thread t = new Thread(task);
t.start();
</code></pre>
<p>如果任务只是打印一句话，<code>Runnable</code>就很合适。但问题是，<strong>如果子线程负责计算 1 到 1000 的和，主线程只能通过创建成员变量最后赋值才能拿到。</strong></p>
<pre><code>public class Demo {
    public static int total = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -&gt; {
            int sum = 0;
            for (int i = 0; i &lt;= 1000; i++) {
                sum += i;
            }
            total = sum;
        };
        Thread t = new Thread(task);
        t.start();
        t.join();
        System.out.println("total = " + total);
    }
}
</code></pre>
<p>但如果子线程有很多的话，还得自己管理<strong>结果存放、等待条件和线程通知</strong>，代码会很繁琐。</p>
<p>所以 <strong>JUC 就提供了 <code>Callable&lt;V&gt;</code> 这样一个更贴合这类任务的接口</strong>；</p>
<p>Callable 是一个 interface。<code>Callable&lt;V&gt;</code>描述“执行完后会产生一个 <code>V</code>类型结果”的任务，相当于把线程封装了一个“返回值”。方便给程序猿借助多线程的方式计算结果。</p>
<p>用<code>Callable</code>去完成上面一个计数代码是实现如下：</p>
<pre><code>public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable&lt;Integer&gt; callable = new Callable&lt;Integer&gt;() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 0; i &lt;= 1000; i++) {
                    result += i;
                }
                return result;
            }
        };
        FutureTask&lt;Integer&gt; futureTask = new FutureTask&lt;&gt;(callable);
        Thread t = new Thread(futureTask);
        t.start();
        System.out.println(futureTask.get());
    }
}
</code></pre>
<p><code>Callable</code> 接口和 <code>Runnable</code> 接口是并列关系；<code>Callable&lt;V&gt;</code> 是要重写一个 <code>V call()</code>方法，而<code>Runnable</code>是要重写一个<code>void run()</code>方法。</p>
<p>使用<code>Callable</code>要注意，上面的代码有三个重要角色，</p>
<p><code>Callable&lt;Integer&gt;</code>描述要做的计算，以及返回什么结果、</p>
<p><code>FutureTask&lt;Integer&gt;</code>包装任务，记录它是否完成，并保存结果、</p>
<p><code>Thread</code>真正安排新线程执行任务。</p>
<p><code>Thread</code> <strong>本身不提供获取结果的方法，所以就需要凭 <code>FutureTask</code> 对象来拿到结果。至于为什么 Thread 不能直接接收 <code>Callable</code> 对象，则是因为需要解耦合。</strong></p>
<p><code>Thread</code> 就是线程，要和任务这个概念能剥离开。线程不需要关心任务是啥样的任务（是否有返回值）。</p>
<p>再补充一下上述关于<code>futureTask.get()</code>部分：</p>
<blockquote>
<p>拿买麻辣烫举例。</p>
<ul>
<li><code>Callable</code>：交给后厨的制作任务。</li>
<li>工作线程：做餐的厨师。</li>
<li><code>FutureTask</code>：你拿到的取餐票。</li>
<li><code>get()</code>：拿着票取餐。</li>
</ul>
<p>你拿到取餐票时，麻辣烫<strong>可能还没做好</strong>。因此调用 <code>get()</code> 时：</p>
<ul>
<li><strong>任务已经完成</strong>：直接取到结果。</li>
<li><strong>任务还没完成</strong>：当前调用 <code>get()</code> 的线程等待，直到有结果。</li>
</ul>
<p>这里等待的是<strong>调用 <code>get()</code> 的主线程</strong>；负责计算的子线程仍在工作。<code>get()</code> 也只是等待并取结果，<strong>不会替你启动任务</strong>。所以如果忘了 <code>thread.start()</code>，却直接调用 <code>get()</code>，主线程就可能一直等下去。</p>
<p>还有一种情况：子线程在 <code>call()</code> 中计算失败、抛出异常。主线程调用 <code>get()</code> 时不会得到正常结果，而会收到 <code>ExecutionException</code>；如果主线程在等待期间被中断，则会收到 <code>InterruptedException</code>。</p>
</blockquote>
<p>同时，上面也补充了一个之前没学习到的线程创建方法，通过传入<code>Callable</code>对象。这里再总结一下：</p>
<ol>
<li>继承 Thread（定义单独的类/匿名内部类）</li>
<li>实现 Runnable（定义单独的类/匿名内部类）</li>
<li>lambda</li>
<li>实现 Callable（定义单独的类/匿名内部类）</li>
<li>线程池，ThreadFactory</li>
</ol>
<h3>2.2 ReentrantLock</h3>
<p>ReentrantLock 即可重入互斥锁，是更经典风格的锁，与 synchronized 是并列关系。都是用来实现互斥效果，保证线程安全。</p>
<p>ReentrantLock 提供了三个方法：</p>
<ul>
<li>lock()：加锁，如果获取不到锁就死等。</li>
<li>unlock()：解锁。</li>
<li>unlock(超时时间)：加锁，如果获取不到锁，等待一定的时间之后就放弃加锁。</li>
</ul>
<p>它的基本加锁和解锁使用方式就像这样</p>
<pre><code>import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}
</code></pre>
<ul>
<li><code>lock.lock()</code>：尝试取得锁。如果被其他线程持有，就等待取得它。只有 <code>lock()</code> 成功返回，才应进入负责释放这把锁的代码。</li>
<li><code>try</code>：执行需要保护的 <code>count++</code>。</li>
<li><code>finally</code>：无论代码正常结束还是抛出异常，都调用 <code>unlock()</code>。</li>
</ul>
<p><code>trylock()</code><strong>这个方法的话，就是抢不到就先不等。</strong></p>
<p>他不会阻塞，加锁成功返回 true，加锁失败返回 false。调用者判定返回值，决定接下来需要做什么~</p>
<p>它还可以设置超时时间，等待时间达到超时时间再返回 true/false</p>
<pre><code>import java.util.concurrent.TimeUnit;

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        count++;
    } finally {
        lock.unlock();
    }
} else {
    System.out.println("等了最多一秒，仍没拿到锁");
}
</code></pre>
<p>你可以想下面这样理解：</p>
<p>使用一间会议室</p>
<ul>
<li><code>lock()</code>：会议室有人，就等到空出来。</li>
<li><code>tryLock()</code>：看一眼，有人就马上离开。</li>
<li>限时 <code>tryLock()</code>：等一会儿，过了约定时间就改变安排。</li>
</ul>
<p>但现在 synchronized 优化做的越来越好，优先还是使用 synchronized</p>
<p>这里的重点是 synchronized 和 ReentrantLock 之间区别：</p>
<ol>
<li>synchronized 是关键字（内部是 JVM 通过 C++实现的），ReentrantLock 则是标准库的类（完全由 Java 编写的）</li>
<li>synchronized 通过代码块控制加锁解锁，ReentrantLock 需要 lock/unlock 方法，需要注意 unlock 不被调用的问题</li>
<li>ReentrantLock 除了提供 lock，unlock 之外还提供了一个方法 tryLock()</li>
<li>ReentrantLock 提供了公平锁的实现（默认是非公平的）</li>
<li>ReentrantLock 搭配的等待通知机制，是 Condition 类，相比 wait notify 来说功能更强大一些</li>
</ol>
<h3>2.3 信号量 Semaphore</h3>
<p>前面了解到<code>synchronized</code>和<code>ReentrantLock</code>通常是在描述：同一时刻只允许一个线程进入。</p>
<p>而<strong>信号量</strong>就是为了<strong>同一时刻，管理多份资源的工具</strong>。</p>
<p>信号量，用来表示“可用资源的个数”，本质上就是一个计数器，你可以这样理解；</p>
<pre><code>Semaphore parking = new Semaphore(4);
</code></pre>
<p>这里的 4 表示的是一开始有 4 个表示可用许可，也可以理解为停车位：</p>
<table>
<thead>
<tr>
<th><strong>发生的事</strong></th>
<th><strong>可用许可</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>停车场刚开放</td>
<td>4</td>
</tr>
<tr>
<td>第一辆车进入</td>
<td>3</td>
</tr>
<tr>
<td>第二辆车进入</td>
<td>2</td>
</tr>
<tr>
<td>又有两辆车进入</td>
<td>0</td>
</tr>
<tr>
<td>第五辆车尝试进入</td>
<td>等待</td>
</tr>
<tr>
<td>一辆车驶出</td>
<td>释放 1 个许可，等待的车有机会进入</td>
</tr>
</tbody>
</table>
<p>我称其为“许可”，是因为<code>Semaphore</code>只管理可用数量，并不真的创建停车位对象。Java 文档也是按“取得一个许可，归还一个许可”来描述它的。</p>
<p>可以参考一下<a href="https://docs.oracle.com/en/java/javase/23/docs/api/java.base/java/util/concurrent/Semaphore.html">Oracle官方文档</a></p>
<h4>2.3.1 <code>acquire()</code>申请资源</h4>
<p><code>Semaphore</code>提供的其中一个方法，<code>acquire()</code>就是会先申请许可，再使用资源。在车进入停车场前，会先调用</p>
<pre><code>parking.acquire();
</code></pre>
<blockquote>
<p>其含义就是，我要申请一个许可，拿到了，才能往下执行。</p>
<p>如果还有许可，可用数量减一，线程继续执行。如果许可已经用完，线程就等待，直到有其他线程归还许可；等待期间也可能因中断而抛出 <code>InterruptedException</code>。</p>
</blockquote>
<p><img src="./10.png" alt="" /></p>
<p>所以说也可以称其为 P 操作。</p>
<p>信号量的一个特殊情况：初始值为 1 的信号量，取值要么是 1 要么是 0（二元信号量）。这就等价于“锁”</p>
<pre><code>public class Demo {
    public static void main(String[] args) throws InterruptedException {
        // 指定可用资源的个数是 "4"
        Semaphore semaphore = new Semaphore(3);
        semaphore.acquire();
        System.out.println("进行一次 P 操作");
        semaphore.acquire();
        System.out.println("进行一次 P 操作");
        semaphore.acquire();
        System.out.println("进行一次 P 操作");
        semaphore.acquire();
        System.out.println("进行一次 P 操作");
    }
}
</code></pre>
<h4>2.3.2 <code>release()</code>释放资源</h4>
<p>在车离开停车场的时候就调用：</p>
<pre><code>parking.release();
</code></pre>
<p>可用许可增加一个，正在等待的线程便有机会取得它。这一步也就是所谓的<strong>V 操作：归还一个许可，可用数量加一</strong>。</p>
<p>注意：<code>Semaphore</code> 不会像 <code>ReentrantLock</code> 那样检查“是不是持有锁的那个线程”，对它来说，调用一次 <code>release()</code>，就只是把当前许可数量加 1。多调用一次 <code>release()</code>，许可数量就可能增加。就比如，原本只打算容纳 4 辆车，但没有申请资源的前提下使用了<code>release()</code>就会变为容纳 5 辆车。</p>
<p>所以代码里必须保证，<strong>成功 <code>acquire()</code> 之后才 <code>release()</code>，并且用完一定归还。</strong></p>
<p>示例代码：</p>
<pre><code>public class Demo {
    public static void main(String[] args) {
        Semaphore parking = new Semaphore(4);

        Runnable car = new Runnable() {
            @Override
            public void run() {
                boolean acquired = false;

                try {
                    // 1. 先申请车位
                    parking.acquire();
                    acquired = true;

                    // 2. 拿到车位后，才能进入停车场
                    System.out.println(Thread.currentThread().getName() 
                            + " 进入停车场");

                    Thread.sleep(1000); // 模拟停车 1 秒
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();

                } finally {
                    // 3. 只有确实拿到车位，才归还车位
                    if (acquired) {
                        parking.release();
                        System.out.println(Thread.currentThread().getName() 
                                + " 离开停车场");
                    }
                }
            }
        };
        for (int i = 1; i &lt;= 20; i++) {
            new Thread(car, "车辆" + i).start();
        }
    }
}
</code></pre>
<h3>2.4 CountDownLatch</h3>
<p>在上一节的<code>Semaphore</code>有一个计数器，用来表示，<strong>现在还允许多少个线程进入</strong>；</p>
<p><code>CountDownLatch</code>也有计数器，但问题完全不同，它是<strong>还有多少件事没完成？等它们全部完成，我才能继续</strong>。</p>
<p>就拿跑步举例，还有多少件事没完成？等它们全部完成，我才能继续。</p>
<pre><code>CountDownLatch latch = new CountDownLatch(10);
</code></pre>
<p>这里的 10 表示的是还需要收到 10 次完成通知。</p>
<p>每次有一名选手结束比赛就调用<code>latch.countDown()</code>，计数器减 1，然后负责统计的线程调用<code>latch.await()</code>。</p>
<p>如果计数器还不是 0，那么这个线程就的等待；计数器到 0 之后，它才会继续执行。<code>countDown()</code>的调用者不用等其他选手，通知完就可以结束自己的工作。</p>
<p>过程能记为：</p>
<table>
<thead>
<tr>
<th>事件</th>
<th>剩余计数</th>
</tr>
</thead>
<tbody>
<tr>
<td>比赛开始，等待 10 名选手</td>
<td>10</td>
</tr>
<tr>
<td>第一名完成任务</td>
<td>9</td>
</tr>
<tr>
<td>又有 8 名完成任务</td>
<td>1</td>
</tr>
<tr>
<td>最后一名完成任务</td>
<td>0</td>
</tr>
<tr>
<td>等待中的统计线程</td>
<td>可以继续</td>
</tr>
</tbody>
</table>
<p>此处的计数器表示<strong>剩余任务数</strong>，不是“还有多少个名额可供使用”。这就是它和<code>Semaphore</code>最根本的区别。</p>
<p>虽然说这 10 个线程也可以分别 <code>join()</code>，但<code>CountDownLatch</code>更直接的地表达需求，“<strong>我不关心逐个等待哪条线程；我要等待 10 项任务都发出完成通知</strong>”</p>
<p>和信号量哪里可以相反的理解，<code>Semaphore</code> 问的是“现在还能进几个”；<code>CountDownLatch</code> 问的是“还差几个没完成”。</p>
<pre><code>public class Demo{
    public static void main(String[] args) throws InterruptedException {
        // 现在把整个任务拆成 10 个部分. 每个部分视为是一个 "子任务".
        // 可以把这 10 个子任务丢到线程池中, 让线程池执行.
        // 当然也可以安排 10 个独立的线程执行.

        CountDownLatch latch = new CountDownLatch(10);
        ExecutorService executor = Executors.newFixedThreadPool(4);
        for (int i = 0; i &lt; 10; i++) {  // 此处创建任务数要与latch个数相同
            int id = i;
            executor.submit(() -&gt; {
                System.out.println("子任务开始执行: " + id);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("子任务结束执行: " + id);
                latch.countDown();
            });
        }
        // 这个方法阻塞等待所有的任务结束
        // 此处的 a =&gt; all
        latch.await();
        System.out.println("所有任务执行完毕");
        executor.shutdown();
}
</code></pre>
<hr />
<p>对于这一部分可能会出的面试题：</p>
<ol>
<li>线程同步的方式有哪些？</li>
</ol>
<blockquote>
<p><code>synchronized</code>、<code>ReentrantLock</code>、<code>Semaphore</code>、原子类等都可以用于线程同步。</p>
</blockquote>
<ol>
<li>为什么有了<code>synchronized</code>还需要 JUC 下的 Lock？</li>
</ol>
<blockquote>
<p>以 JUC 的 <code>ReentrantLock</code> 为例：</p>
<ul>
<li><code>synchronized</code> 使用时不需要手动释放锁；<code>ReentrantLock</code> 使用时需要手动释放锁，使用起来更加灵活。</li>
<li><code>synchronized</code> 在申请锁失败时，会一直等待；<code>ReentrantLock</code> 可以通过 <code>tryLock()</code> 的方式尝试获取锁，也可以等待一段时间后放弃。</li>
<li><code>synchronized</code> 不提供公平锁机制；<code>ReentrantLock</code> 默认是非公平锁，也可以通过构造方法传入 <code>true</code> 开启公平锁模式。</li>
<li><code>synchronized</code> 可以通过 <code>Object</code> 的 <code>wait()</code> / <code>notify()</code> 实现等待与唤醒；<code>ReentrantLock</code> 可以搭配 <code>Condition</code> 实现等待与唤醒，并且能够更加精确地控制不同等待队列中的线程。</li>
</ul>
</blockquote>
<ol>
<li><code>AtomicInteger</code> 的实现原理是什么？</li>
</ol>
<blockquote>
<p>基于 CAS 机制，伪代码如下：</p>
</blockquote>
<pre><code>class AtomicInteger {
    private int value;

    public int getAndIncrement() {
        int oldValue = value;

        while (!CAS(value, oldValue, oldValue + 1)) {
            oldValue = value;
        }

        return oldValue;
    }
}
</code></pre>
<ol>
<li>信号量是什么？一般应用在哪些场景？</li>
</ol>
<blockquote>
<p>信号量，用来表示“可用资源的个数”。本质上就是一个计数器</p>
<p>使用信号量可以实现“共享锁”。比如某个资源允许 3 个线程同时使用，那么就可以使用 P 操作作为加锁，V 操作作为解锁，前三个线程的 P 操作都能顺利返回，后续线程再进行 P 操作就会阻塞等待，直到前面的线程执行了 V 操作。</p>
</blockquote>
<ol>
<li>解释一下 ThreadPoolExecutor 构造方法的参数的含义</li>
</ol>
<blockquote>
<p>参考另一篇“多线程：应用案例”</p>
</blockquote>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 并发进阶（一）：锁策略与 synchronized 原理</title>
    <link href="https://blog.syrena.cn/posts/javaee-multithreading-locks/javaee-multithreading-locks/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-multithreading-locks/javaee-multithreading-locks/</id>
    <published>2026-09-28T00:00:00.000Z</published>
    <updated>2026-09-28T00:00:00.000Z</updated>
    <summary>系统梳理乐观锁、悲观锁、自旋锁、读写锁等常见锁策略，并深入理解synchronized的加锁过程与优化机制.</summary>
    <content type="html"><![CDATA[<p>这一部分的内容主要针对面试，所以内容相对有些干巴~</p>
<h2>1.常见锁策略</h2>
<h3>1.1 乐观锁和悲观锁</h3>
<p>不知道你第一次见是不是误以为：一个锁比较开心，另一个锁比较消极；</p>
<p>不过其实描述的主要是：对于“接下来会不会发生线程冲突”</p>
<p><strong>悲观锁：</strong></p>
<blockquote>
<p>每次访问共享数据都先假设别人可能会修改，因此线上锁；其他线程如果也需要访问，就只能等待锁释放。</p>
</blockquote>
<p>悲观锁的想法比较保守，<strong>加锁的时候，预测接下来的锁竞争的情况会非常激烈，就需要针对这样的激烈情况额外做一些工作。</strong></p>
<p>还是继续拿公司举例子，假设财务办公室有一份账本。张三觉得“这个账本这么重要，李四王五说不定随时都会来改，我还是先把门锁上。”</p>
<p><strong>乐观锁：</strong></p>
<blockquote>
<p>先假设并发冲突发生的概率不高，在真正提交更新时再检查是否发生冲突。</p>
</blockquote>
<p>乐观锁的想法就相反，<strong>加锁的时候，预测接下来的锁竞争的情况不激烈，就不需要做额外的工作。</strong></p>
<p>乐观锁和悲观锁的对比就像下面一样说的一样</p>
<p>比如：现在张三和李四想找老师问问题。</p>
<blockquote>
<p>张三比较悲观：“老师平时这么忙，我直接过去估计也是白跑。”所以张三就先发消息“老师，你下午两点有空吗？”</p>
<p>老师确认“有空”后，张三才过去~</p>
</blockquote>
<p>像上面这样就是<strong>悲观锁</strong></p>
<blockquote>
<p>李四就比较乐观“老师应该没这么忙吧，我直接过去”。于是李四直接跑办公室，如果老师正好有空，那最好直接把问题解决~</p>
<p>这样就根本没有提前预约产生额外流程。</p>
<p>如果老师正忙，“哦，那算了，我下次再来。”</p>
<p>虽然这次失败了，但他能够识别到“现在发生冲突了”然后重新尝试~</p>
</blockquote>
<p>这就是<strong>乐观锁</strong>的类比</p>
<p>两种策略没有绝对优劣，关键看当前场景。</p>
<p>比如冲突非常频繁，你还一直乐观“失败 再试 失败 又试”，就像知老师每天都忙得要死，你还天天直接跑办公室碰碰运气。最后反而白跑很多次，一直浪费资源~这就比较适合悲观锁了~</p>
<p>反过来，假如有 1000 次操作，可能只有 1 次冲突。如果每一次都申请锁-&gt;等待-&gt;解锁，这就有点像：老师一年都闲的要死，你每次都为“1+1”等于几之前，还提前三天预约~这种冲突概率很低的场景，就更适合乐观锁策略。</p>
<p><strong>那么乐观锁到底怎么知道“别人改没改”？</strong></p>
<p>这个问题就先埋下来~</p>
<p>而悲观锁其实很好理解：<strong>真的把锁拿到手</strong></p>
<p>那乐观锁既然不提前把别人挡住，那它凭什么知道别人有没有改过数据？</p>
<p>一个典型的方案就是版本号~</p>
<blockquote>
<p>就比如：当前数据是<strong>余额 = 100</strong>，<code>version = 1</code></p>
<p>线程 A 读到：<code>100 / version 1</code></p>
<p>准备修改为：<code>50</code></p>
<p>真正提交的时候再看 <code>version</code> 是否等于 1</p>
<p>如果还是<code>version = 1</code>就说明我读取之后没人改过；如果发现<code>version != 1</code>就说明我不在的时候有人动过这份数据~那么本次修改就失败</p>
</blockquote>
<p>这其实就是为我后面要说的<code>CAS</code> 埋个伏笔</p>
<p>既然提到锁，就不得不提一下<code>synchronized</code>，<code>synchronized</code> 开始时会倾向使用更乐观、开销较低的策略；如果发现锁竞争逐渐频繁，则会逐步转向更悲观、开销更高的处理方式。</p>
<h3>1.2 重量级锁和轻量级锁</h3>
<p>从前面我们学习到了，<strong>锁的核心目的是保证原子性 / 互斥；</strong></p>
<p><img src="./01.png" alt="" /></p>
<p>可以先暂时不用深入研究 JVM 和操作系统底层是如何实现锁的，就记住<strong>Java 的锁当然不是凭空出现的，它最终还是要依赖 CPU 提供的原子指令以及操作系统提供的线程调度能力</strong>。</p>
<p>那么<strong>重量级锁</strong>和<strong>轻量级锁</strong>是什么意思？</p>
<ul>
<li><strong>重量级锁</strong></li>
</ul>
<p>这里提到的重量级，不是说把锁本身“更大”，而是<strong>发生锁竞争时，为了实现线程之间的互斥，需要付出的系统开销比较大。</strong></p>
<p>就比如，当一个线程获取不到锁时，可能需要让该线程进入阻塞状态。等到锁被释放以后，又需要将这个线程唤醒，让它重新参与 CPU 调度。</p>
<p>这其中可能涉及到：</p>
<ol>
<li>用户态与内核态之间的切换</li>
<li>线程阻塞与唤醒</li>
<li>线程上下文切换</li>
<li>操作系统线程调度</li>
</ol>
<p>这些操作相对于普通的用户态代码来说成本比较高，所以被称为<strong>重量级锁。</strong></p>
<ul>
<li><strong>轻量级锁</strong></li>
</ul>
<p>轻量级锁的思路则是：如果锁竞争并不严重，就尽量不要马上让线程进入阻塞状态，而是在用户态先尝试解决竞争。</p>
<p>就比如：JVM 可以利用 CPU 提供的 <strong>CAS 原子指令</strong>，或者让线程进行短暂的自旋，尝试获取锁。</p>
<p>整个过程中线程可能并没有真正进入阻塞状态，因此也就避免了比较昂贵的线程阻塞、唤醒和上下文切换。这也就是所说的“轻量级”。</p>
<p>上面提及的<strong>用户态</strong> / <strong>内核态</strong>指的就是：</p>
<blockquote>
<p>用户态：程序自己在用户空间里完成，不需要操作系统直接介入线程的阻塞和唤醒。</p>
<p>内核态：当锁竞争严重、线程需要被阻塞或唤醒时，需要操作系统内核参与线程调度，可能产生用户态与内核态切换以及线程上下文切换。</p>
</blockquote>
<p>你可以这么理解用户态和内核态：</p>
<p>假设你去银行，在大厅外面，自己能完成的事情（填表、看号码、整理材料）这些事情自己就能做，这就可以类比为用户态。</p>
<p>但是 有些事情你没有权限自己完成，必须交给拥有更高权限的工作人员，所以只能去柜台，让银行工作人员处理，这就可以类似内核态。</p>
<p>对于上面所说的“重量级锁”和“轻量级锁”，主要区别并不是谁更安全，而是谁的实现成本更高。所以还是那句话：<strong>没有哪一种策略永远最好，关键看当前的竞争情况。</strong></p>
<h3>1.3 自旋锁和挂起等待锁</h3>
<p>我们知道，线程在竞争锁时可能会遇到一种情况：线程 A 已经持有锁，线程 B 尝试获取锁但失败了。对于线程 B 通常会有两种不同的处理思路：</p>
<ol>
<li>继续占用 CPU，不断尝试获取锁 -&gt; 自旋等待</li>
<li>暂时放弃 CPU，进入阻塞等待状态 -&gt; 挂起等待</li>
</ol>
<p>这也就是我这一节要说的自旋锁和挂起等待锁~</p>
<p><strong>自旋锁：</strong></p>
<p>刚刚说到的第一种思路就是自旋锁（继续占用 CPU，不断尝试获取锁）</p>
<p>按照传统的处理方式，如果线程获取锁失败，可以直接进入阻塞状态，将 CPU 让给其他线程。这样做虽然不会一直浪费 CPU，但是<strong>线程阻塞、唤醒以及上下文切换本身也是有成本的</strong>。</p>
<p>于是就产生了一个问题：</p>
<p>假设线程 A 虽然正在持有锁，但是它马上就要释放了，如果这时候线程 B 马上进入阻塞，那么阻塞和唤醒线程的成本，可能反而比等待锁释放的时间还长~</p>
<p>所以换一种思路就是先不要马上阻塞线程，让线程继续运行一小段时间，不断尝试获取锁，这就是自旋的含义。</p>
<p>自旋你可以抽象理解为：</p>
<pre><code>while (获取锁失败) {
    // 继续尝试获取锁
}
</code></pre>
<p>注意哈，这只是帮助理解的伪代码。</p>
<p>我让 AI 解释了下，实际 JVM 或并发组件中的实现会更加复杂，并不会简单地让线程无条件无限循环。</p>
<p>再补充一下，<strong>自旋锁其实是属于一种偏轻量级的等待策略。自旋线程仍然处于运行状态，因此仍然会占用 CPU~</strong></p>
<p><strong>挂起等待锁：</strong></p>
<p>挂起等待锁其实就是另一种思路了：既然暂时获取不到，那我就先不抢了，把 CPU 让出来。</p>
<p>从过程上来看其实就是“我拿不到锁，我先休息，把 CPU 让出来，等锁释放后再唤醒我”</p>
<p>此处所说的“挂起”并不是说 Java 线程永远停止，而是线程暂时进入等待 / 阻塞状态，不再持续占用 CPU 执行。</p>
<p>这里说到的<strong>自旋锁</strong>和<strong>挂起等待锁</strong>你可以这样理解一下：</p>
<blockquote>
<p>想象一下，去追求一个女神。当男生像女神表白后，女神说：你是个好人，但是我有男朋友了~~</p>
<p>挂起等待锁：陷入沉沦不能自拔......过了很久很久之后，突然女神发消息过来，“要不咱俩试试？”（注意，这个很长时间间隔里，女神可能已经换了好几个男票了）</p>
<p>自旋锁：死皮赖脸坚韧不拔，仍然每天坚持地和女神说早安晚安。一旦女神和上一任分手，那么就能立刻抓住机会上位。</p>
</blockquote>
<p>我让 GPT 总结了上面的内容给出了对比表格：</p>
<table>
<thead>
<tr>
<th>对比</th>
<th>自旋等待</th>
<th>挂起等待</th>
</tr>
</thead>
<tbody>
<tr>
<td>获取锁失败后</td>
<td>不断尝试获取锁</td>
<td>线程进入等待 / 阻塞</td>
</tr>
<tr>
<td>是否继续占用 CPU</td>
<td>是</td>
<td>通常不持续占用 CPU</td>
</tr>
<tr>
<td>是否需要线程阻塞</td>
<td>通常不需要</td>
<td>需要</td>
</tr>
<tr>
<td>是否涉及调度</td>
<td>较少</td>
<td>通常需要</td>
</tr>
<tr>
<td>等待时间很短</td>
<td>更合适</td>
<td>阻塞成本可能不划算</td>
</tr>
<tr>
<td>等待时间很长</td>
<td>会浪费 CPU</td>
<td>更合适</td>
</tr>
</tbody>
</table>
<p>所以上述两种方式没有绝对的好坏，而是适用的场景不同~</p>
<p>其实还是回到了锁竞争的激烈程度：</p>
<p><strong>悲观锁 -&gt; 重量级锁 -&gt; 挂起等待锁</strong></p>
<p><strong>乐观锁 -&gt; 轻量级锁 -&gt; 自旋锁</strong></p>
<h3>1.4 公平锁和非公平锁</h3>
<p>这一部分其实更好理解些，主要说的是：</p>
<blockquote>
<p>如果很多线程都在等同一把锁，那么这把锁到底要不要讲“先来后到”？</p>
</blockquote>
<p>假设现在有三个线程，分别是 A，B，C</p>
<p>A 先尝试获取锁，获取成功。然后 B 再尝试获取锁，获取失败，阻塞等待。然后 C 也尝试获取锁，C 也获取失败，也阻塞等待。</p>
<p>那么，当线程 A 释放锁的时候，会有两种情况：</p>
<p><strong>公平锁</strong>：遵循“先来后到”，B 比 C 先来，所以当 A 释放锁的时候，B 会先于 C 获取到锁。</p>
<p><strong>非公平锁</strong>：不遵循“先来后到”，B 和 C 都有可能获取到锁。</p>
<blockquote>
<p>其实你可以认为，非公平锁也能定义为公平，因为锁默认情况下，就相当于“概率均等”，操作系统针对线程的调度是随机的~</p>
</blockquote>
<p>操作系统线程调度本身可以看成带有随机性；如果没有额外机制限制，锁通常不会天然保证公平。如果要实现公平，就需要额外的数据结构记录线程到来的先后顺序。</p>
<p>下面这个例子希望更能加深对公平锁和非公平锁的印象~</p>
<p><img src="./02.png" alt="" /></p>
<p><img src="./03.png" alt="" /></p>
<p>再补充一句，<code>synchronized</code> 其实是非公平锁，在之前学习 <code>synchronized</code> 的时候就说到，当一个线程释放锁以后，其他等待的线程会重新竞争，并不一定严格按照先来后到。</p>
<h3>1.5 可重入锁和不可重入锁</h3>
<p>在之前的初阶已经稍微接触过，只是当时是为了说明<code>synchronized</code>不会让线程“自己把自己锁死”。</p>
<p><strong>可重入</strong>的意思就是：</p>
<p><strong>同一个线程已经拿到第一把锁以后，还可以再次获取同一把锁。</strong></p>
<p><strong>或者逼入一个递归函数里有加锁操作，递归过程中这个锁会阻塞自己吗？如果不会，那么这个锁就是可重入锁（因为这个原因可重入锁也被称为递归锁）。</strong></p>
<p>就比如下面这样：</p>
<pre><code>public class Demo {
    public synchronized void method1() {
        System.out.println("method1");
        method2();
    }
    public synchronized void method2() {
        System.out.println("method2");
    }
}

Demo demo = new Demo();
demo.method1();
</code></pre>
<p>比如我 <code>new</code> 了一个 <code>Demo</code>，调用了 <code>method1</code>，<code>method1</code> 内部又调用了<code>method2</code>，它们同样都是 <code>synchronized</code>；</p>
<p>如果是可重入锁，<code>method2</code> 允许再次进入。</p>
<p>但如果是不可重入锁，<code>method2</code> 就会被拒之门外，然后<code>method1</code> 又无法释放锁。此时就会发生死锁情况。</p>
<p><img src="./04.png" alt="" /></p>
<p>这样的锁就称为<strong>不可重入锁。</strong></p>
<p><code>synchronized</code> <strong>是可重入锁</strong></p>
<h3>1.6 普通互斥锁和读写锁</h3>
<p>在多线程环境下，对共享数据的访问通常可以分为两类：<strong>读取数据</strong>和<strong>修改数据</strong></p>
<p>如果只是两个线程同时读取数据，两个线程都不会修改数据，因此一般不会破坏数据的一致性。</p>
<p>但如果一个线程读取数据，一个线程修改数据，这样就可能产生问题；同样，如果是两个线程都在修改数据也必须得保持互斥。</p>
<p>你可以这样认为：</p>
<p><img src="./05.png" alt="" /></p>
<p><strong>普通互斥锁</strong></p>
<p>普通互斥锁的问题主要是，<strong>即使多个线程之间只是读取操作，它们仍然不能同时执行。</strong></p>
<p>就像是之前学习 <code>synchronized</code> 一样：</p>
<pre><code>public synchronized int getData() {
    return data;
}

public synchronized void setData(int data) {
    this.data = data;
}
</code></pre>
<p>这样虽然能保证线程安全，但是假设三个线程都执行<code>getData()</code>只是读取数据。但又由于<code>synchronized</code>是互斥的。也就是说<strong>即使多个线程之间只是读取操作，他们仍然不能同时执行。</strong></p>
<p>线程安全是保证了，但是并发能力下降了。</p>
<p>所以这时候就产生了一个新的思路：<strong>既然读操作不会修改数据，那就允许多个线程同时读取；</strong></p>
<p><strong>读写锁</strong></p>
<p>这就是<strong>读写锁</strong>由来。</p>
<p><strong>读写锁</strong>并不是只有“一把锁”，而是把锁拆为了读锁 （Read Lock） 和 写锁 （Write Lock） 分别对待读取操作和修改操作。</p>
<p>Java 标准库提供了 <code>ReentrantReadWriteLock</code>，它的内部提供了两种锁，分别是读锁类 <code>ReentrantReadWriteLock.ReadLock</code> 和写锁类 <code>ReentrantReadWriteLock.WriteLock</code>。</p>
<p>以上两个类都提供了 lock / unlock 方法分别进行加锁和释放锁。</p>
<p>读锁最大的特点就是：<strong>读锁之间可以共享</strong>，所以读锁也被称为<strong>共享锁</strong></p>
<p>而写锁的话就必须保证互斥，所以读锁也能理解为独占锁，因为同一时间只能有一个线程持有写锁。</p>
<p>这样就实现了上面那种图片的效果了：</p>
<p>读锁 + 读锁：不互斥</p>
<p>写锁 + 写锁：互斥</p>
<p>读锁 + 写锁：互斥</p>
<p>也就是说，只要有线程持有写锁，其他线程既不能获取读锁，也不能获取写锁</p>
<p>反过来，只要还有线程持有读锁，写线程就不能获取写锁</p>
<p>使用实例如下：</p>
<pre><code>ReentrantReadWriteLock readWriteLock =
        new ReentrantReadWriteLock();
ReentrantReadWriteLock readWriteLock =
        new ReentrantReadWriteLock();

Lock readLock = readWriteLock.readLock();
Lock writeLock = readWriteLock.writeLock();
// 读操作加锁
public int getData() {
    readLock.lock();

    try {
        return data;
    } finally {
        readLock.unlock();
    }
}
// 写操作加锁
public int getData() {
    readLock.lock();

    try {
        return data;
    } finally {
        readLock.unlock();
    }
}
</code></pre>
<p>再补充一句，<code>synchronized</code>不是读写锁~</p>
<p>对于以上锁的内容，一般面试考察的就是概念性的</p>
<p><strong>然后对于读写锁来说还有一个要注意的地方：</strong></p>
<p>在<code>ReentrantReadWriteLock</code>中，<strong>持有写锁的线程，可以继续获取读锁</strong>。</p>
<p>例如：</p>
<p><img src="./06.png" alt="" /></p>
<p>这种操作叫做：<strong>锁降级</strong></p>
<p>也就是从写锁变成了读锁，权限从<strong>独占</strong>变成了<strong>共享读取</strong>~</p>
<h2>2.<code>synchronized</code> 原理</h2>
<p><code>synchronized</code> 前面已经学过了，但结合上面说到的锁策略问题，你可以将 <code>synchronized</code> 先理解成：</p>
<ol>
<li>开始倾向乐策略竞争频繁后转向更悲观的处理</li>
<li>开始使用轻量级实现竞争严重后可能转成重量级锁</li>
<li>轻量级阶段可能使用自旋</li>
<li>非公平锁</li>
<li>可重入锁</li>
<li>不是读写锁</li>
</ol>
<p><strong>JVM 将</strong><code>synchronized</code><strong>锁分为无锁、偏向锁、轻量级锁、重量级锁状态。</strong></p>
<h3>2.1 加锁过程</h3>
<p><img src="./07.png" alt="" /></p>
<p><code>synchronized</code>不是一上来就用最重的方案，你可以这么理解：</p>
<p>竞争不严重的时候，能简单处理就简单处理。</p>
<p>当竞争越来越严重，简单办法搞不定，再逐渐换成更重的办法。</p>
<p>先不急着背这几个词，可以把线程放进一个具体场景。</p>
<pre><code>class Counter {
    private final Object lock = new Object();
    private int count = 0;

    public void add() {
        synchronized (lock) {
            count++;
        }
    }
}
</code></pre>
<p>假设线程 A、B 都调用同一个 <code>Counter</code> 对象的 <code>add()</code>。它们竞争的就是该对象中的同一个<code>lock</code>。</p>
<h4>无锁：目前还没有人使用这把锁</h4>
<p><code>lock</code>刚创建出来的时候还没有线程进入这段同步代码~</p>
<p>这时候线程 A 来执行 <code>add</code>，他想做的事情就只是：</p>
<blockquote>
<p>“我要进入<code>synchronized(lock)</code>，修改<code>count</code>”</p>
</blockquote>
<p>JVM 需要保证 A 修改期间，如果 B 也来了，B 不能同时修改~</p>
<p>但现在只有 A，一个竞争者都没有。但如果每次都按“许多线程争锁”的方式处理，成本就白花了。</p>
<h4>偏向锁：一直都是 A 在使用</h4>
<p>我还是拿之前的例子来吧：</p>
<p>假设我是个妹子，我要谈男朋友，我又希望把男朋友换的快一些，那么就有下面两个步骤：</p>
<blockquote>
<ol>
<li>和当前男朋友分手</li>
</ol>
<p>我需要 zuo 一 zuo，打一打拳，逐渐消耗他的耐心~~</p>
<p>我再谈分手，一定得是哭的梨花带雨，让它认为都是他的名字~</p>
<ol>
<li>和下一个小哥哥培养感情</li>
</ol>
</blockquote>
<p>当我最开始谈这个男朋友的时候，我和他做各种情侣之间做的事情，但是从不和他确认关系（搞暧昧）这就让他能够更好的满足我对于男朋友的需求~</p>
<p>然后当有一天我对他厌烦了，这个时候就不必麻烦了，直接和他说，咱们不要再见面了~</p>
<p>但这样搞暧昧的手段也有一定<strong>副作用</strong>，万一有别的妹子也在接近我的小哥哥，我的小哥哥也可以瞬间把我给踹了，然后用同样的话“咱们只是普通朋友”应对我</p>
<p>所以我只要发现了有妹子试图接近我家哥哥，只要一有这样的苗头，我就立即和我家哥哥确认关系，并且朋友圈官宣~让其他妹子就可以 gun 远了~</p>
<p>ok，像上述这样的过程，就是偏向锁的过程~</p>
<p>进行<code>synchronized</code>刚一上来，不是真加锁，而是只是简单做一个标记（搞暧昧）</p>
<p>这个标记，非常轻量，相比于加锁解释来说，效率高很多~</p>
<p>如果没有其他线程来竞争这个锁，最终当前线程执行到解锁代码，也就只是简单清除上述标记即可（不涉及真加锁、真解锁），就像是搞暧昧，不真确立关系，后续分手就很快</p>
<p>如果有其他线程来竞争，就抢先一步，在另一个线程拿到锁之前，抢先拿到锁。也就是真加锁了， 偏向锁 -&gt;轻量级锁。其他线程只能阻塞等待~</p>
<p><strong>本质上也是懒汉模式思想的体现</strong></p>
<p><strong>偏向锁之所以省事，前提是基本没有别的线程来争；前提变了，策略也得变。</strong></p>
<h4>轻量级锁：有竞争，但也许很快就结束</h4>
<p>假设 A 正在执行：</p>
<pre><code>synchronized (lock) {
    count++;
}
</code></pre>
<p>此时 B 恰好也来了。B 发现自己暂时拿不到 <code>lock</code>。</p>
<p>如果同步代码只有一个 <code>count++</code>，A 很可能马上就执行完了。此时此刻让 B 挂起，等操作系统以后再唤醒它，可能比“稍等一下”还费事。</p>
<p><strong>尝试取得锁</strong>：用 CAS 检查并尝试更新与锁有关的状态。（先把 CAS 理解成一个“确认状态仍符合预期，才尝试修改”的动作）<br />
<strong>短暂自旋</strong>：如果这次没取得锁，B 可以短暂重试，看看 A 是否已经释放。</p>
<p>这就和上面提到的<strong>自旋锁策略</strong>接上了，“我先不去休息，在小试一小会，也许你马上就用完了”</p>
<p>但 如果 A 持续时间很长，B 一直原地重试。这时候 B 就会持续消耗 CPU，所以自旋不会毫无节制地一直进行。短暂等待仍拿不到锁，就需要考虑成本更合适的处理方式。</p>
<h4>重量级锁：持续竞争，就让线程等待</h4>
<p>假设 A 进入同步代码后久久不退出，B 试了多次还是拿不到锁；或者有更多线程都来争这把锁。</p>
<p>此时要是再试一下就不划算了~所以就需要才需更重的处理方式，也就是重量级锁：有 JVM 的监视器机制管理竞争，拿不到锁的线程可能进入等待，等锁释放后再被唤醒、重新争取。</p>
<hr />
<p>对于上述的内容我是这样认为</p>
<p>无锁 --&gt; 偏向锁：代码进入<code>synchronized</code>的代码块</p>
<p>偏向锁 --&gt; 轻量级锁：拿到偏向锁的线程运行过程中，遇到了其他线程尝试竞争这个锁</p>
<p>轻量级锁 --&gt; 重量级锁：JVM 发现，当前竞争锁的情况非常激烈</p>
<p>当前 JVM 中，只提供了“锁升级”不能“锁降级”。<strong>注意此处我说的锁降级与前面提到的 <code>ReentrantReadWriteLock</code> 的锁降级不是同一种。</strong></p>
<p><strong><code>ReentrantReadWriteLock</code> 的“锁降级”，是并发锁 API 的读写权限转换；<code>synchronized</code> 所谓“锁升级”，是 JVM/HotSpot 对 Monitor 的底层实现形态变化。</strong></p>
<h3>2.2 其他的优化操作</h3>
<h4>锁消除</h4>
<p>锁消除就是<strong>编译器和 JVM 判断某些锁实际上不会发生线程竞争，于是把没有必要的加锁、解锁操作直接去掉</strong>~</p>
<p>就比如，都知道 <code>StringBuffer</code> 很多方法本身就带同步。</p>
<pre><code>StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
</code></pre>
<p>但如果，sb 只是一个方法里的局部变量，而且整个过程只有当前线程能访问。那么就根本没有线程在跟你抢，那这时候还“加锁、append、解锁”就纯粹是白忙活~</p>
<p>所以 JVM 如果能分析出这个对象不会被其他线程访问，那么就可以把这些没有意义的同步操作消掉~</p>
<p>再注意一下，JVM 只有在能够判断这个锁不会发生真实竞争时，才能进行这种优化</p>
<h4>锁粗化</h4>
<p>再看另一种情况：</p>
<pre><code>for (int i = 0; i &lt; 1000; i++) {
    synchronized (lock) {
        count++;
    }
}
</code></pre>
<p>假设 JVM 按代码表面上的范围处理，每轮循环都要 <strong>取得锁 -&gt; count++ -&gt; 释放锁</strong></p>
<p>如果是循环一千次，那么就重复一千次</p>
<p>但问题是如果<strong>这些操作紧挨着进行，反复去取得、释放同一把锁，本身也会有成本。</strong></p>
<p>所以锁粗化就是<strong>在合适的情况下，JVM 可能把多次相邻的加锁范围合并，减少反复申请和释放锁的次数。</strong></p>
<p>举个例子：假设你要向领导交代三个任务</p>
<ul>
<li><strong>方式一</strong>：打电话交代任务 1，挂断；再打电话交代任务 2，挂断；再打一次交代任务 3.</li>
<li><strong>方式二</strong>：打一次电话，把三个任务交代完，再挂断。</li>
</ul>
<p>你给领导打电话（对领导加锁），这个时候领导需要全心全意应付你，没法做别的事情，所以必然会对整体效率产生影响~</p>
<p><strong>所以方法二就省掉了重复的成本，锁粗化成一把锁省的也是反复“取得、释放”的成本</strong>。所以上面的加锁可以优化成这样：</p>
<pre><code>synchronized (lock) {
    for (int i = 0; i &lt; 1000; i++) {
        count++;
    }
}
</code></pre>
<p>所以 <code>synchronized</code> 真正厉害的地方，不只是“能加锁”，而是 JVM 在背后替我们做了很多优化。</p>
<p>那么对于如果有类似下面的面试题回答就不算困难了~</p>
<ol>
<li>什么是偏向锁？</li>
</ol>
<blockquote>
<p>偏向锁不是真的加锁，而只是在锁的对象头中记录一个标记（记录该锁所属的线程）。如果没有其他线程参与竞争锁，那么就不会真正执行加锁操作，从而降低程序开销。一旦真的涉及到其他的线程竞争，再取消偏向锁状态，进入轻量级锁状态。</p>
</blockquote>
<ol>
<li>synchronized 实现原理是什么？</li>
</ol>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 并发实践：单例模式、阻塞队列、线程池与定时器</title>
    <link href="https://blog.syrena.cn/posts/javaee-multithreading-cases/javaee-multithreading-cases/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-multithreading-cases/javaee-multithreading-cases/</id>
    <published>2026-09-19T00:00:00.000Z</published>
    <updated>2026-09-19T00:00:00.000Z</updated>
    <summary>通过单例模式、阻塞队列、线程池和定时器等案例，掌握Java多线程编程的实际应用与核心实现.</summary>
    <content type="html"><![CDATA[<h2>1. 单例模式</h2>
<p>对于《初阶》这篇文章我们已经介绍了<code>synchronized</code>和<code>volatile</code>，初阶知识点很多是在单独解决某个线程安全。为了切实理解如何应用，我引入了单例模式这个典型例子来深度讲解一下：</p>
<p><strong>单例模式</strong>首先是<strong>设计模式</strong>里的其中一个，那么什么是单例模式，什么又是<strong>设计模式</strong>？</p>
<p>我先回答<strong>设计模式</strong>：</p>
<blockquote>
<p>设计模式：设计模式不是某一个 Java 语法，而是针对某类常见问题总结出来的一套代码设计思路。而单例模式就是其中非常经典的一种。</p>
<p>你可以这么理解，设计模式可以类比象棋中的“棋谱”，针对红方的一些走法，黑方应招的时候有一些固定的套路，那么按照套路来走局势就不会吃亏。</p>
<p>那么软件开发中当然也会有很多常见的“问题场景”，针对这些问题场景，大佬就总结出了一些固定的套路，按照这个套路来实现代码也不会吃亏。</p>
</blockquote>
<p>设计模式和框架也不是一个概念，框架属于“硬性要求”，设计模式属于“软性要求”；</p>
<p><strong>单例模式</strong>：</p>
<blockquote>
<p>希望某一个类，在程序中只存在一份的实例。</p>
</blockquote>
<p>单例模式，是强制要求一个类不能创建多个对象，</p>
<p>你可以先记成：一个类 -&gt; 只希望存在一个对象 -&gt; 其他地方需要使用时 -&gt; 都按这一份对象</p>
<p><strong>单例模式</strong>具体实现方法有很多，常见的就有“<strong>饿汉</strong>”和“<strong>懒汉</strong>”两种。</p>
<h3>1.1 饿汉模式</h3>
<p>先看最简单的一种：</p>
<pre><code>class Singleton {
    private static Singleton instance = new Singleton();	// 记住这个地方，在类加载阶段触发的
    private Singleton() {
        //...
    }
    public static Singleton getInstance() {
        return instance;
    }
}
</code></pre>
<p>好了，我先来解释一下饿汉模式：类加载的时候，就直接把实例创建出来；</p>
<p>你可以理解为太饿了，等不及别人来要，我先把饭做好放在这里。结合上面的代码就是在<code>Singleton</code>类加载的时候直接 new 出一个<code>instance</code>实例，之后谁需要直接通过<code>getInstance</code>获取；</p>
<p>再解释一下上面的代码，有三个点需要看懂：</p>
<ol>
<li>对于该句<code>private static Singleton instance = new Singleton()</code>，之所以用<code>static</code>，就是让<code>instance</code>这个实例直接属于类本身，而不是属于某个具体对象；</li>
<li><code>private Singleton()</code>这里的构造方法使用<code>private</code>的原因就是为了禁止外部随便创建 <code>Singleton</code> 对象。</li>
<li>想要获取 <code>instance</code>就只有通过静态方法<code>getInstance</code>。这是统一向外提供的对象。</li>
</ol>
<p><img src="./01.png" alt="" /></p>
<p>当然，对于如果需要有多个参数去构建 instance 时，就可以再对多参数的构造方法进行私有化就可以了。</p>
<h3>1.2 懒汉模式</h3>
<p>我们已经知道了“饿汉”就是：我先给你做好。</p>
<p>懒汉也很好理解：你真要用的时候，我再做。</p>
<p>对了，再补充一下“懒”，“懒”在计算机中其实是褒义词，经常能够节省计算机资源开销，我举个例子：</p>
<p>假如有一个很大的文件（千万字的小说），你用编译器打开一般会有两种情况~</p>
<ol>
<li>把所有的内容，都从文件加载到内存中，在显示</li>
<li>只把一部分内容加载并显示</li>
</ol>
<p>第一种情况会明显卡顿，而且就算加载再多你也看不过来；第二种情况就是后续如果用户翻页，随着翻页随时加载后续的数据。</p>
<p>懒汉模式对应的就是第二种情况。而且我用的 Okular PDF 阅读器很明显用的就是懒汉模式，因为当你快速滑动到后面的图片时，很明显会需要加载一下下。</p>
<p>来看一段单线程下的懒汉模式例子：</p>
<pre><code>class SingletonLazy {
    private static SingletonLazy instance = null;
    private SingletonLazy() {

    }
    public static SingletonLazy getInstance() {
        if(instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }
}
</code></pre>
<p>这段懒汉模式与饿汉模式最大的区别就是在创建实例的时候。懒汉模式下，创建实例的时机是在第一次使用的时候，而不是在程序启动的时候。</p>
<p>当然，上述这些内容都是引子，接下来才是正题。</p>
<p>上面两份代码（饿汉、懒汉）<strong>是否存在线程安全问题</strong>，如果不是，该怎么办？（这也是经典的面试题）</p>
<p><img src="./02.png" alt="" /></p>
<p>首先对于饿汉模式，很明显只存在读操作，没有进行任何数据写入。多个线程最多是进行<code>&lt;font style="background-color:rgba(0, 0, 0, 0.06);"&gt;Singleton.getInstance()&lt;/font&gt;</code>去获取<code>&lt;font style="background-color:rgba(0, 0, 0, 0.06);"&gt;instance&lt;/font&gt;</code>而已。所以显然不存在线程安全问题。它的特点就是：不管以后到底用不用，类加载的时候对象就已经创建了。</p>
<p>但是对于懒汉模式，if 条件判断的时候，多线程下假设：</p>
<blockquote>
<p>一个线程在进入 if 判断但还没进入创建实例的时候，另一个线程也在 if 判断，此时另一个线程也会同样进入线程去创建，这样第一个线程创建的实例就会被另一个线程后创建的实例给覆盖掉。</p>
</blockquote>
<p><img src="./03.png" alt="" /></p>
<p>这样第一个线程的<code>instance</code>就被覆盖了，而且随着第二个现成的覆盖操作，第一个线程 new 出来的对象也就会被 GC（grabage collection 垃圾回收） 改释放掉了。像这样的情况就属于是 BUG，所以懒汉模式这样的写法，<code>getInstance</code> 就是线程不安全的。</p>
<p>在生产环境下，new 一个对象所要加载的内存可能是很大了（会有上百 G），所以像这样的操作还是很危险的，可能程序启动就会花费很长时间。</p>
<p><img src="./04.png" alt="" /></p>
<p>因此引入<code>synchronized</code>，将“希望”、“条件”和“修改”打包成原子的操作。但我还是要提醒的是：不是每个不安全的地方写了<code>synchronized</code>就会变得安全的，具体问题具体分析。</p>
<p>修改后得到的是：</p>
<pre><code>class SingletonLazy {
    private static SingletonLazy instance = null;
    private static Object locker = new Object();
    private SingletonLazy() {

    }
    public static SingletonLazy getInstance() {
        synchronized(locker) {
            if(instance == null) {
                instance = new SingletonLazy();
            }
            return instance;
        }
    }
}
</code></pre>
<p>引入加锁之后，后执行的线程就会在加锁的位置阻塞，阻塞到前一个线程解锁。当后一个线程进入条件的时候，前一个线程已经修改完毕。<code>instance</code>不再为<code>null</code>，就不会进行后续的<code>new</code>操作。</p>
<p>但是这样又会产生一个新的问题：</p>
<p><strong>每次调用 <code>getInstance</code> 都要加锁</strong>……多线程情况下，这里的加锁就会相互阻塞，进一步影响程序的执行效率。</p>
<blockquote>
<p>加锁、解锁存在额外开销，而懒汉模式真正危险的其实只是第一次实例创建阶段，所以后续没必要每一次获取实例都继续竞争锁。</p>
</blockquote>
<p>所以继续优化~</p>
<p>通过再一次增加 if 判断条件，进而决定是否要加锁：</p>
<pre><code>class SingletonLazy {
    private static SingletonLazy instance = null;
    private static Object locker = new Object();
    private SingletonLazy() {

    }
    public static SingletonLazy getInstance() {
        if(instance == null) {
            synchronized(locker) {
                if(instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}
</code></pre>
<p>但这个地方，说实话我第一次学习的时候也觉得奇怪，因为在单线程两个一样条件的 if 是无意义的。单线程中，执行流就只有一个，上一个 if 的判定结果和下一个 if 是一样的~</p>
<p>但是在多线程中，两次判定之间，可能存在其他线程，然后其中有线程把 if 中的<code>instance</code>变量给修改了。就导致这里的两次 if 结论可能不同~</p>
<p>所以这里的两次 if 条件相同，只是纯属巧合了...</p>
<p>但是呢，<strong>增加了 if 判断是否加锁还是可能存在一些问题</strong>，比如是否会存在“<strong>内存可见性</strong>”问题？</p>
<blockquote>
<p>答案是可能存在的，编译器优化非常复杂，为了稳妥起见可以给<code>instance</code>直接加上一个<code>volatile</code>，从根本上杜绝了内存可见性问题。</p>
</blockquote>
<p>此处内存可见性就可能存在这种情况：第一个线程读取<code>instance</code>的时候，第二个线程进行修改。第一个线程就没有能及时读取到第二个线程进行的修改。</p>
<p><strong>但这里更关键的问题其实是指令重排序</strong>。指令重排序也是编译器优化的一种体现，编译器会在逻辑不变的前提下，调整你代码执行的先后顺序，以达到提升性能的效果。</p>
<p>涉及指令重排序的代码为创建实例的时候：</p>
<pre><code>instance = new SingletonLazy();
</code></pre>
<p>这一步从概念上可以拆成三步：</p>
<ol>
<li>分配对象内存</li>
<li>执行构造函数，对对象初始化</li>
<li>把对象地址赋值给 instance</li>
</ol>
<p>正常来说这三个步骤是按照 1、2、3 这样的顺序来执行的。但是在指令重排序下，可能成为 1、3、2 这样的顺序，单线程环境下，1、2、3 还是 1、3、2 其实无所谓~</p>
<p>但多线程中是 1、3、2 这样的顺序执行，多线程环境下可能会出现 BUG！！！</p>
<p><img src="./05.png" alt="" /></p>
<p>这样的问题不仅仅是指令重排序引起的，也和双重 if 有关，那么到底该如何解决？</p>
<p>其实透露一下，<code>volatile</code>这个关键字的功能有两个方面：</p>
<ol>
<li>确保每次读取操作都是读内存</li>
<li>关于该变量的读取和修改操作，不会触发重排序</li>
</ol>
<p>所以最终优化版本如下：</p>
<pre><code>class SingletonLazy {
    private static volatile SingletonLazy instance = null;	// 第三处
    private static Object locker = new Object();
    private SingletonLazy() {

    }
    public static SingletonLazy getInstance() {
        if(instance == null) {	// 第二处
            synchronized(locker) {	// 第一处
                if(instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}
</code></pre>
<p>最后切记关于懒汉模式的三个关键点，然后关于面试中真的遇到了该如何应对：</p>
<ol>
<li>先写一个不带线程安全的单例模式</li>
<li>思索片刻，线程不安全，把锁加上</li>
<li>再次思索片刻，加上 if</li>
<li>再次思索片刻，加上 volatile</li>
</ol>
<p>如果是一次写出最终版本，在面试官眼里他会觉得这个问题你正好准备过，此时说明这个题目就考察不出来啥，这题不算，谈下一话题~</p>
<h2>2. 阻塞队列</h2>
<p>我们在 JavaSE 中学过普通队列，应该都知道先进先出。而所谓的<strong>阻塞队列</strong><code>BlockingQueue</code>就是一种比较特殊的队列。但它首先还是<strong>队列</strong>，所以仍然遵守先进先出。可它又多了一个非常重要的一个特点：</p>
<ol>
<li>当队列满的时候，继续入队列就会阻塞，直到有其他线程从队列中取走元素；</li>
<li>当队列空的时候，继续出队列也会阻塞，直到有其他线程往队列中插入元素；</li>
</ol>
<p>阻塞队列的一个典型应用场景就是“生产者消费模型”</p>
<h3>2.1 生产者消费者模型</h3>
<p>···</p>
<p>生产者消费者模型其实就是：通过一个容器来解决生产者和消费者的强耦合问题。</p>
<p>生产者和消费者彼此之间不直接通讯，而是通过阻塞队列来进行通讯，所以伸长这生产完数据之后不用等待消费者处理，直接扔给阻塞队列，消费者不找生产者要数据，而是直接从阻塞队列里取。</p>
<p>生产者消费者模型这名字也很好理解，就比如过年包饺子：</p>
<blockquote>
<p>负责擀饺子皮的这个人就是生产者</p>
<p>负责包饺子的人就是消费者</p>
</blockquote>
<p>这样，擀饺子皮的不关心包饺子的人是谁，包饺子的人也不用关心擀饺子皮的人是谁，大家就各自做各自的就行。</p>
<p>这也就是简单的上下游关系。阻塞队列的目的就是避免生产者和消费者之间绑得很死，能够<strong>解耦合</strong>；</p>
<p>阻塞队列还相当于生产者和消费者之间的缓冲，平衡生产者和消费者的处理能力，也就是<strong>削峰填谷</strong>。</p>
<p><img src="./06.png" alt="" /></p>
<p>一般来说 A 这种上游的服务器，尤其是入口的服务器，干的活更简单，单个请求消耗的资源数少；像 B 这种下游的服务器，通常承担更重的任务量，复杂的计算/存储 工作，单个请求消耗的资源数更多。</p>
<p>引入下面 2 这样的阻塞队列后，就能够有效缓解一下 B 服务器；它会让来自 A 的请求先进入阻塞队列，随后消费者服务器慢慢取，暗号服务器能承受的速度处理；</p>
<p>比如：突然来了 10000 个请求，先存起来，然后每秒处理 100 个；也就是高峰先缓冲，慢慢消化。这也就是所说的“削峰”。</p>
<h3>2.2 标准库中的阻塞队列</h3>
<pre><code>import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue&lt;String&gt; queue = new LinkedBlockingQueue&lt;&gt;();

        for (int i = 0; i &lt; 100; i++) {
            queue.put("aaa");
        }
        System.out.println("队列已经满了. ");

        queue.put("aaa");
        System.out.println("再次尝试 put 元素");
    }
}
</code></pre>
<p>Java 已经给我们准备好了<code>BlockingQueue</code>，这是一个接口，真正实现的类是<code>LinkedBlockingQueue</code>。</p>
<ul>
<li>其中<code>put</code>方法用于<strong>阻塞式的入队列</strong>，<code>take</code>用于<strong>阻塞式的出队列</strong>。</li>
<li><code>BlockingQueue</code>也有<code>offer</code>、<code>poll</code>、<code>peek</code>等方法，但是这些方法不带有阻塞特性。</li>
</ul>
<p><img src="./07.png" alt="" /></p>
<p>会发现，这里有数组、链表以及优先级队列实现的阻塞队列。说到这就想到，面试中常考的 Java 中 ArrayList 和 LinkedList 有什么区别，其实最主要的是他们的时间复杂度</p>
<ul>
<li>顺序表：尾插尾删，O(1)；头插头删、中间位置插入 O(N)</li>
<li>链表：任意位置插入删除 O(1)</li>
</ul>
<p>这里我让 ai 给了个阻塞队列的表格：</p>
<table>
<thead>
<tr>
<th>对比</th>
<th><code>ArrayBlockingQueue</code></th>
<th><code>LinkedBlockingQueue</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>底层结构</td>
<td>数组</td>
<td>链表</td>
</tr>
<tr>
<td>是否有界</td>
<td>必须指定容量</td>
<td>可指定；不指定时容量接近无限</td>
</tr>
<tr>
<td>内存分配</td>
<td>初始化一次数组，后续基本不分配节点</td>
<td>每加入一个元素都要创建 Node</td>
</tr>
<tr>
<td>GC 压力</td>
<td>较小</td>
<td>较大</td>
</tr>
<tr>
<td>put/take 并发</td>
<td>共用一把锁</td>
<td><code>putLock</code>&lt;br/&gt; 和 <code>takeLock</code>&lt;br/&gt; 两把锁</td>
</tr>
<tr>
<td>高并发吞吐</td>
<td>一般</td>
<td>某些生产/消费并发场景更好</td>
</tr>
<tr>
<td>内存可控性</td>
<td>很好</td>
<td>要特别注意容量</td>
</tr>
<tr>
<td>延迟稳定性</td>
<td>通常更好</td>
<td>容易受对象分配和 GC 影响</td>
</tr>
</tbody>
</table>
<p>先来一个例子：</p>
<p>生产者消费者模型使用阻塞队列</p>
<pre><code>public class Demo7 {
    
    public static void main(String[] args) {
        BlockingQueue&lt;Integer&gt; queue = new LinkedBlockingQueue&lt;&gt;();
        Thread producer = new Thread(() -&gt; {
            int num = 0;
            while (true) {
                try {
                    queue.put(num);
                    System.out.println("生产元素" + num);
                    num++;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"producer");

        Thread consumer = new Thread(() -&gt; {
            try {
                Integer num = queue.take();
                System.out.println("消费元素" + num);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        producer.start();
        consumer.start();
    }
}
</code></pre>
<p><img src="./08.png" alt="" /></p>
<p>关于此处要是不设参数的话，默认会是一个非常大的值...</p>
<p><img src="./09.png" alt="" /></p>
<p>用计算机计算就能知道是 21 亿多，</p>
<p><img src="./10.png" alt="" /></p>
<p>另外阻塞队列中，没有提供一个“阻塞的获取队首元素的操作”。</p>
<p>对于上述代码中，如果让其中一个 sleep 等待一下，让生产者速度比消费者速度快一下会如何？</p>
<p>其实只是生产者会很快填满阻塞队列，然后消费者会一个一个从中取出，就按他自己的节奏来。其实按照上述代码就算存满了，其实也占不了多大内存，我们已经知道该队列最大最多 21 亿个元素，每个元素就是一个 int 的 4 字节空间。总共就是 80 亿字节，转换为也就是 8G；</p>
<p>具体转换如下：</p>
<p>Thousand 千 =&gt; K</p>
<p>Million 百万  =&gt; M</p>
<p>Billin 十亿     =&gt; G</p>
<h3>2.3 阻塞队列模拟实现</h3>
<pre><code>class MyBlockingQueue {
    // 此处不使用泛型
    private String[] data = null;
    // 队首
    private int head  = 0;
    // 队尾
    private int tail = 0;
    // 元素个数
    private int size = 0;
    public MyBlockingQueue(int capacity) {
        data = new String[capacity];
    }

    public void put(String elem) throws InterruptedException {
        // 当队列满，需要阻塞
        synchronized (this) {
            while (size &gt;= data.length) {
                this.wait();
            }
            data[tail] = elem;
            tail++;
            // 再判断是否需要循环
            if(tail &gt;= data.length) {
                tail = 0;
            }
            size++;
            // 针对队列空的话，需要唤醒阻塞
            this.notify();
        }
    }

    public String take() throws InterruptedException {
        // 当队列空，需要阻塞
        synchronized (this) {
            while (size == 0) {
                this.wait();
            }
            String ret = data[head];
            head++;
            if (head &gt;= data.length) {
                head = 0;
            }
            size--;
            // 针对队列满的话，需要唤醒阻塞
            this.notify();
            return ret;
        }
    }
}
</code></pre>
<p>上述使用的阻塞队列是循环的队列：</p>
<p><img src="./11.png" alt="" /></p>
<p>由于队列满或者队列空的时候需要阻塞队列，然后又由于<strong>队列不空</strong>或者<strong>不满</strong>的时候需要被唤醒，所以<code>put</code>和<code>take</code>方法中分别都需要到<code>wait</code>和<code>notify</code>。</p>
<p><img src="./12.png" alt="" /></p>
<ul>
<li>注意，上述代码中，使用<code>synchronizd</code>的目的是，解决多个线程同时修改队列导致的数据竞争，避免两个线程可能同时操作同一个位置导致队列混乱，保证 <code>head</code>、<code>tail</code>、<code>size</code>、<code>data</code> 的操作是线程安全的。另外，此处为避免混乱还需结合着<code>wait</code>和<code>notify</code>一起使用。</li>
</ul>
<pre><code>if (size &gt;= data.length) {
    this.wait();
}
</code></pre>
<ul>
<li>另外关于为什么使用的是<code>while</code>而不是<code>if</code>。问题在于被<code>notify</code>唤醒，并不代表你现在一定可以继续执行。假设：队列容量为 1，且当前队列已占满。现在有两个生产者 P1、P2 都想放元素。于是当前 P1、P2 都进入 <code>wait</code>等待。然后消费者取走占满的元素，唤醒了两个等待线程，假设是 P1 先拿到锁，现在队列就又满了。如果是<code>if</code>那么 P2 从<code>wait</code>后面继续执行，不会再次判断条件。于是就直接进行<code>data[tail] = elem</code>。但如果是<code>while</code>的话，P2 会<strong>再重新检查一遍是否真的被唤醒</strong>~</li>
</ul>
<p>对于使用<code>wait</code>这个地方，其实标准库中就已经建议使用<code>while</code>来替换<code>if</code>~</p>
<p><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Object.html#wait()">java17中关于wait的相关解释</a></p>
<p><img src="./13.png" alt="" /></p>
<p>对了，<strong>上述所说的</strong> <code>wait</code>，<strong>其实也不只是能被</strong> <code>notify</code> <strong>唤醒</strong>，<strong>还可以被</strong><code>Interrupt</code><strong>这样的方法给中断</strong>。如果用 <code>if</code> 作为 <code>wait</code> 的判断条件，此时也还是存在被提前唤醒的风险！</p>
<h2>3 线程池</h2>
<p>之前学习 String 已经初步了解了常量池相关知识，常量池主要就是为了<strong>复用常量数据</strong>，减少重复存储。它与线程池的共同点就是<strong>池化</strong>的思想：</p>
<blockquote>
<p>把创建成本较高或可以重复利用的资源提前保存起来，后续重复使用。</p>
</blockquote>
<p>那么线程池的意义就是，我还是举个例子吧：</p>
<blockquote>
<p>一家餐厅每天都会不断有客人来。如果每来一桌客人，就临时招聘一个服务员，服务完再把他辞退，成本会非常高。<br />
更合理的做法是提前准备固定数量的服务员，客人来了就分配给空闲服务员处理；忙不过来时，客人先排队等待。</p>
</blockquote>
<p>所以线程池的最大好处就是提前创建并复用线程，避免频繁创建和销毁线程带来的开销。</p>
<p>对于频繁创建和销毁线程，目前已经有三种解决情况了：</p>
<ol>
<li>线程池，不反复创建，直接使用；</li>
<li>协程：不再让每个任务都对应一个重量级线程；</li>
<li>虚拟线程，Java21+之后开始的；</li>
</ol>
<p>但目前使用最普遍还是线程池，剩下两个可以后面再了解~</p>
<p>关于<strong>为什么叫</strong>“<strong>池</strong>”，主要还是因为池化的这种思想。都是池子里会提前准备一些资源，需要的时候拿出用即可，用完不是销毁，而是放回池子里继续再使用。</p>
<h3>3.1 标准库中的线程池</h3>
<p>其实，线程池不需要我们每次自己实现。Java 的标准库已经提供好给我们了。</p>
<p>先讲 Java 供我们可以自定义的线程池：</p>
<p>首先先来区分一下这些名称</p>
<blockquote>
<p>Executor          → 接口</p>
<p>ExecutorService   → 线程池常用接口</p>
<p>Executors         → 工具类，帮我们创建线程池</p>
<p>ThreadPoolExecutor → 线程池核心实现类</p>
</blockquote>
<h4><code>ThreadPoolExecutor</code> 自定义线程池</h4>
<p><strong>核心方法是</strong> <code>submit(Runnable)</code>，不过这个方法你在<code>ThreadPoolExecutor</code>没看到很正常，因为<code>ThreadPoolExecutor</code>是继承了<code>submit</code>这个方法。</p>
<p>继承关系是：<code>Executor</code> &lt;- <code>ExecutorService</code> &lt;- <code>AbstractExecutorService</code> &lt;- <code>ThreadPoolExecutor</code></p>
<p>而 <code>submit</code>方法主要是在<code>ExecutorService</code>中定义的，在 <code>AbstractExecutorService</code> 中给出了实现，所以 <code>ThreadPoolExecutor</code> 直接继承了它。</p>
<p>然后通过 Runnable 描述一段要执行的任务，再通过<code>submit</code>任务放到线程池中，线程池就会执行这些任务；</p>
<p>但构造<code>ThreadPoolExecutor</code>线程池有些麻烦，需要了解一些相关参数：</p>
<p><img src="./14.png" alt="" /></p>
<p>有四个构造参数，一般面试会问这些参数都是什么意思（经典面试题）</p>
<p>面试官最想听的就是你对于第七个参数的理解~</p>
<p>这里我们直接了解最困难的最后一个</p>
<pre><code>ThreadPoolExecutor(
        int corePoolSize,
        int maximumPoolSize,
        long keepAliveTime,
        TimeUnit unit,
        BlockingQueue&lt;Runnable&gt; workQueue,
        ThreadFactory threadFactory,
        RejectedExecutionHandler handler
)
</code></pre>
<ul>
<li><code>int corePoolSize</code>：核心线程数，表示至少有多少个线程。线程池一旦创建，这些线程也要随之创建，知道整个线程池销毁，这些线程才会销毁。</li>
<li><code>int maximumPoolSize</code>：最大线程数，表示核心线程数+非核心线程数（不繁忙就销毁，繁忙就再创建），我们知道，线程不是越多越好的。</li>
<li><code>long keepAliveTime</code>：非核心线程允许空闲的最大时间。临时增加出来的线程，不可能永远留在那里。</li>
<li><code>TimeUnit unit</code>：时间单位，单看<code>keepAliveTime = 10</code>你不知道单位是秒？分钟？小时？这里是枚举类型
<img src="./15.png" alt="" /></li>
<li><code>BlockingQueue&lt;Runnable&gt; workQueue</code>：任务队列，也就是我们前面学到的阻塞队列（线程池里的任务队列）。当然了此处的队列可以自己选择合适的根基（数组/链表、指定 capacity、指定是否要带有优先级/比较规则）。线程池本质上也是生产者消费者模型，调用<code>submit</code>就是在生产任务，线程池里的线程就是在消费任务。</li>
<li><code>threadFactory</code>：线程工厂，用来定义线程池创建工作线程的方式。线程池需要新线程时，会调用 <code>ThreadFactory.newThread(Runnable)</code> 创建线程。通过线程工厂可以统一设置线程名称、优先级、是否为守护线程以及异常处理方式等。它只负责“怎么创建线程”，不负责决定“什么时候创建线程”。</li>
</ul>
<p>对于<strong>为什么使用线程工厂</strong>，我给到下面的解释：</p>
<p>假设我有一个 Point，他最终都要保存平面直角坐标，单床剪一个点时，可以有两种输入方式：</p>
<p>方法 1，使用直角坐标(x,y)；方法 2，使用极坐标(r,a)</p>
<p>但问题就在构造方法这里，我们会自然的写出两个构造方法：</p>
<pre><code>class Point {
    public Point(double x, double y) {
        // 按照直角坐标创建
    }
    public Point(double r, double a) {
        // 按照极坐标创建
    }
}
</code></pre>
<p>但是 Java 不允许，原因就是 Java 的方法重载看的是方法名、参数个数、参数类型、参数顺序。所以对于 Java 来说这俩就完全一样。所以就用工厂方法来解决。就改为不直接让用户调用构造方法，而是提供<strong>不同名字的方法</strong>。就比如下面：</p>
<pre><code>class PointFactory {
    public static Point makePointByXY(double x, double y) {
        Point p = new Point();
        p.x = x;
        p.y = y;
        return p;
    }

    public static Point makePointByRA(double r, double a) {
        Point p = new Point();
        p.x = r * Math.cos(a);
        p.y = r * Math.sin(a);
        return p;
    }
}
</code></pre>
<p><strong>注意类型</strong>是 <code>Point</code>并且还是<strong>静态方法</strong>。<strong>工厂方法的核心就是通过静态方法把构造对象 new 的过程、各种属性初始化的过程封装起来，并且提供多组静态方法，实现不同情况的构造</strong>。此时调用就可以这样：</p>
<pre><code>Point p1 = PointFactory.makePointByXY(3, 4);
Point p2 = PointFactory.makePointByRA(5, Math.PI / 2);
</code></pre>
<p>当然也可以直接在<code>Point</code>里面写静态工厂方法：</p>
<pre><code>class Point {

    private double x;
    private double y;

    private Point(double x, double y) {
        this.x = x;
        this.y = y;
    }
    public static Point fromXY(double x, double y) {
        return new Point(x, y);
    }
    public static Point fromRA(double r, double a) {
        return new Point(
                r * Math.cos(a),
                r * Math.sin(a)
        );
    }
}

// 使用
Point p1 = Point.fromXY(3, 4);
Point p2 = Point.fromRA(5, Math.PI / 2);
</code></pre>
<p>以上两种方法分别是：</p>
<blockquote>
<p>构造方法：负责真正构造对象</p>
<p>静态工厂方法：负责提供不同的创建方式</p>
</blockquote>
<p>以上就是对工厂类的解释。</p>
<ul>
<li><code>RejectedExecutionHandler</code>：拒绝策略，显然他的意思就是字面意思。<code>submit</code>会把任务添加到任务队列中，任务队列是阻塞队列，队列满了再添加，就会阻塞~对于线程池来说，发现入队列操作时，队列满了，不会真的触发“入队列操作”，不会真阻塞，而是执行拒绝策略相关代码。</li>
</ul>
<p>为什么不使用生产者消费者模型是因为如果调用<code>submit</code>就阻塞，就会是这个线程就没法干别的事情，对于用户那里就会迟迟拿不到请求的响应，用户等了很久，直观上看到的现象就是“卡了”。所以，与其是“卡了”不如直接告诉我“失败”。</p>
<p>拒绝策略就是下面四个，我会再一步解释一下：</p>
<table>
<thead>
<tr>
<th>拒绝策略</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>AbortPolicy</code></td>
<td>直接抛异常</td>
</tr>
<tr>
<td><code>CallerRunsPolicy</code></td>
<td>让提交任务的线程自己执行</td>
</tr>
<tr>
<td><code>DiscardOldestPolicy</code></td>
<td>丢掉队列中最老的任务</td>
</tr>
<tr>
<td><code>DiscardPolicy</code></td>
<td>直接丢掉新任务</td>
</tr>
</tbody>
</table>
<p>拿快递站举个例子，现在所有员工都忙，任务本也写满了，临时工也招到上限。</p>
<p>此时来了一个快件...</p>
<p><code>AbortPolicy</code>：“接受不了了！”，直接报错；</p>
<p><code>CallerRunsPolicy</code>：“谁把这个任务送过来的，谁自己干。”</p>
<p><code>DiscardOldestPolicy</code>：“把任务本里最老的一项扔掉，给新任务腾位置。”</p>
<p><code>DiscardPolicy</code>：“新来的直接不要。”</p>
<p>意思就是上面这么个意思</p>
<p><img src="./16.png" alt="" /></p>
<h4><code>Executors</code>封装好的常用线程池</h4>
<p>如果按照上面的自定义线程池那样，每次使用线程池就得自己填写那么多参数还是太麻烦了。而且很多时候我们的需求其实也很普通：我就是想要一个固定 10 个线程的线程池。</p>
<p>所以 <strong>Java 就提供了一个封装好的 <code>Executors</code> 这样一个工具类</strong>。它就相当于帮我们把常见的<code>ThreadPoolExecutor</code>参数提前组合好了，并且提供了几种常见线程池创建方式。</p>
<p>下面都是<code>Executors</code>的方法：</p>
<p><img src="./17.png" alt="" /></p>
<p>我只大概了解其中几个：</p>
<ul>
<li><code>newFixedThreadPool</code>：创建一个固定 n 个线程的线程池。</li>
</ul>
<pre><code>ExecutorService pool =
        Executors.newFixedThreadPool(10);
pool.submit(() -&gt; {
    System.out.println("hello");
});
</code></pre>
<ul>
<li><code>newCachedThreadPool</code>：创建线程数量可以动态变化的线程池。业务多的时候会增加线程；业务少的时候空闲线程逐渐回收。</li>
<li><code>newScheduledThreadPool</code>：可以完成延迟执行、定期执行；正好等到后面要说的定时器再展开。</li>
</ul>
<p>综上也能看出来，<strong>其实</strong> <code>Executors</code> <strong>本质上就是</strong> <code>ThreadPoolExecutor</code> <strong>类的封装</strong>。</p>
<h3>3.2 线程池的关闭</h3>
<p>线程池使用完后，需要主动进行关闭。<code>ThreadPoolExecutor</code>中常用的两个就是：<code>shutdown()</code>和<code>awaitTermination()</code>。</p>
<p>其中<code>shutdown()</code>用于<strong>发起线程池关闭通知</strong>，而<code>awaitTermination()</code>用于<strong>等待线程池真正关闭完成</strong>。</p>
<h4><code>shutdown()</code>方法</h4>
<p><code>executor.shutdown()</code>，调用<code>shutdown</code>之后，线程池会进入关闭状态，会有以下的过程：</p>
<ol>
<li>不再接受新任务；</li>
<li>已经提交并正在执行的任务会继续执行；</li>
<li>已经进入任务队列的任务也会继续执行；</li>
<li>当所有已提交任务执行完成后，线程池中的工作线程才会逐渐退出。</li>
</ol>
<p>注意，<code>shutdown</code><strong>不会阻塞调用它的线程</strong>~毕竟，谁会断了自己的路呀~</p>
<h4><code>awaitTermination()</code>方法</h4>
<p>如果程序需要确认线程池中的任务已经执行结束，可以配合：</p>
<pre><code>executor.awaitTermination(10, TimeUnit.SECONDS);
</code></pre>
<p>作用就是：让当前线程等待线程池结束，最多等待指定的时间。</p>
<p>通常需要先调用<code>executor.shutdown()</code>，再调用<code>executor.awaitTermination(10, TimeUnit.SECONDS)</code></p>
<p>例如：</p>
<pre><code>executor.shutdown();

try {
    boolean finished = executor.awaitTermination(10,TimeUnit.SECONDS);

    if (finished) {
        System.out.println("线程池已经完全关闭");
    } else {
        System.out.println("等待超时，线程池仍未关闭");
    }

} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}
</code></pre>
<p>此处，<code>awaitTermination(10,TimeUnit.SECONDS)</code>表示当前线程最多等待线程池 10 秒。然后这个方法还会返回一个<code>boolean</code>值：true 表示在线程等待期间，线程池已经完全终止；false 表示等待时间已经到了，但线程仍然没有结束。</p>
<h3>3.3 线程池模拟实现</h3>
<pre><code>import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

// 实现一个固定线程个数的线程池
public class MyThreadPool {
    private BlockingQueue&lt;Runnable&gt; queue = null;
    public MyThreadPool(int n) {
        // 初始化线程池，创建固定个数的线程
        // 此处使用ArrayBlockingQueue作为任务队列，容量为1000
        queue = new ArrayBlockingQueue&lt;&gt;(1000);

        // 创建线程
        for (int i = 0; i &lt; n; i++) {
            Thread t = new Thread(() -&gt; {
                try {
                    while (true) {
                        Runnable task = queue.take();
                        task.run();
                    }
                } catch(InterruptedException e){
                    throw new RuntimeException(e);
                }
            });
            // 启动线程
            t.start();
        }
    }

    // 提交任务
    public void submit(Runnable task) throws InterruptedException {
        queue.put(task);
    }

    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i &lt; 100; i++) {
            int id = i;
            pool.submit(() -&gt; {
                System.out.println(Thread.currentThread().getName() + " id = " + id);
            });
        }
    }
}
</code></pre>
<p>理解线程池最直接的方法之一，就是<strong>自己实现一个简化版本</strong>，并且能<strong>不看模板自己实现一个</strong>，这样你才能明白到这段代码中很多细节以及整个运转流程，真的...</p>
<p>真正的 <code>ThreadPoolExecutor</code> 内部还包含核心线程数、最大线程数、拒绝策略、线程回收、线程池状态管理等大量机制，但如果只看最核心的思想，其实可以概括为两部分：</p>
<ul>
<li>提前创建一批可以重复使用的工作线程</li>
<li>使用一个任务队列保存暂时还没有被执行的任务</li>
</ul>
<p>上面那部分代码虽然很简单，但已经包含了线程池最基本的运行过程。我来解释一下部分含义：</p>
<ol>
<li><strong>使用阻塞队列保存任务</strong></li>
</ol>
<p>我在线程池内部首先定义了一个<code>BlockingQueue&lt;Runnable&gt;</code>，创建了一个容量为 1000 的阻塞队列，队列中保存的不是线程，而是<code>Runnable</code>，也就是一个个等待执行的任务。当调用<code>pool.submit(task)</code>时，任务并不会立即由<code>submit</code>方法执行，而只是通过<code>queue.put(task)</code>被放入任务队列中。</p>
<p>整个过程就是</p>
<p>“<strong>提交任务  -&gt;  submit(task)  -&gt;  queue.put(task)  -&gt; 等待工作线程处理</strong>”</p>
<p>使用阻塞队列还有一个重要的原因：它可以很好的协调“提交任务的线程”和“执行任务的线程”。</p>
<p>当队列暂时没有任务时，工作线程执行：<code>queue.take()</code>，会进入阻塞状态，而不是不断循环检查队列。这样的话线程在没有任务的时候就不会一直占用的 CPU。</p>
<ol>
<li><strong>提前创建固定数量的工作线程</strong></li>
</ol>
<p>在线程池的构造方法中，已经提前创建好了 n 个线程<code>MyThreadPool pool = new MyThreadPool(10)</code>，这就意味着线程池创建时会直接启动 10 个工作线程。这些线程当然不是执行一次任务以后就结束，而是在 while 里不断循环去 take 任务：</p>
<pre><code>while (true) {
    Runnable task = queue.take();
    task.run();
}
</code></pre>
<ol>
<li><code>take()</code> <strong>负责取得任务</strong>，<code>run()</code> <strong>负责执行任务</strong></li>
</ol>
<p>在工作线程中最核心的代码实际上只有两行：</p>
<pre><code>Runnable task = queue.take(); 
task.run();
</code></pre>
<p><code>queue.take()</code>负责从任务队列中获取一个<code>Runnable</code>任务，当其他线程通过<code>queue.put(task)</code>提交新任务以后，线程池中的一个的等待中的工作线程就可以取得这个任务。</p>
<p><code>task.run()</code>才是真正执行任务的地方，当然这里还有注意去分析下<code>t.start()</code>和<code>task.run()</code>。</p>
<p>例如：</p>
<pre><code>Thread t = new Thread(...);
t.start();
</code></pre>
<p>表示启动一个工作线程。之后这个工作线程从队列中取得<code>Runnable task</code>，再调用<code>task.run()</code>表示由当前这个已经启动的工作线程去执行任务。</p>
<p>👌以上就是我对模拟实现的讲解~</p>
<h2>4. 定时器</h2>
<p>上面我们已经学习了线程池，他所解决的主要是<strong>任务来了以后交给谁执行</strong>，而现在要学习了定时器要解决的是<strong>这个任务是么时候执行</strong>。</p>
<p>你可以把定时器类比一下为“闹钟”，先把任务和时间约好，等时间到了以后再去执行对应的代码。就像是计算机网络里学到的网络通信超过一定时间没有响应就重连，或者让某个缓存数据几秒以后过期。</p>
<h3>4.1 标准库中的<code>Timer</code></h3>
<p>标准库提供了一个 Timer 类，Timer 类的<strong>核心方法</strong>就是<code>Schedule</code>；</p>
<p><code>Schedule</code>包含<strong>两个参数</strong>，第一个参数指定即将要执行的任务代码，第二个参数指定多长时间之后执行（单位为毫秒）</p>
<pre><code>Timer timer = new Timer();

timer.schedule(new TimerTask() {
    @Override
    public void run() {
        System.out.println("hello");
    }
}, 3000);
</code></pre>
<p><strong>以上是匿名内部类写法，与讲线程的创建方法的时候类似。</strong></p>
<p>主要就是以下<strong>三个要点</strong>：</p>
<ol>
<li><strong>创建了子类（父类 TimerTask），匿名</strong></li>
<li><strong>重写了 run</strong></li>
<li><strong>new 了子类的实例</strong></li>
</ol>
<p>正常描述任务，是 <code>Runnable</code>，但在定时器这里稍微特殊一些，把 <code>Runnable</code> 封装了一下为 <code>TimeTask</code>。那么 <code>Timer</code> 和 <code>TimerTask</code> 分别又是什么呢？</p>
<p>你可以这样理解，就是<code>Timer</code><strong>负责什么时候执行，也就是调度者</strong>；<code>TimerTask</code><strong>负责执行什么，也就是任务</strong>；</p>
<p>和线程池类似，<strong>线程池</strong>负责调度并执行 <code>Runnable</code> 任务，而 <code>Timer</code> 负责定时调度 <code>TimerTask</code> 任务；<strong>实现 <code>Runnable</code> 时需要实现 <code>run()</code> 方法，继承 <code>TimerTask</code> 时同样需要重写 <code>run()</code> 方法</strong>，因为真正的任务逻辑都写在 <code>run()</code> 中。</p>
<pre><code>Thread thread = new Thread() {
    @Override
    public void run() {
        System.out.println("hello");
    }
};

thread.start();
</code></pre>
<p><img src="./18.png" alt="" /></p>
<p>以上就是两者的大概对比；还有就是和线程池一样，Timer 中也包含前台线程，阻止进程结束。</p>
<h3>4.2 定时器的模拟实现</h3>
<p>首先我们需要了解<strong>定时器的构成</strong>：</p>
<ol>
<li>一个带优先级队列（不需要使用 PriorityBlockingQueue，容易<strong>死锁</strong>）</li>
<li>队列中的每个元素是一个 Task 对象</li>
<li>Task 中带有一个时间属性，队首元素就是即将要执行的任务</li>
<li>同时有一个 worker 线程一直扫描队首元素，看对队首元素是否需要执行</li>
</ol>
<p>而我们要<strong>实现定时器</strong>则需要按照下面这四部分：</p>
<ol>
<li>创建一个类<code>MyTimeTask</code>，表示一个任务</li>
<li>定时器中，能够管理多个任务的集合类</li>
<li>实现 schedule 方法，把任务添加到队列中即可</li>
<li>额外创建一个线程，负责执行队列中的任务</li>
</ol>
<p>和线程池不同，线程池是只要队列不为空，就立即取任务并执行。此处还要看队首元素的时刻是否到了，时刻到才能执行，时刻不到不能执行。</p>
<p>这里的话，我喜欢先讲解再展示代码哈：</p>
<ul>
<li>我先创建两个类，分别是定时器类<code>MyTimer</code>,以及存放任务的 <code>MyTimerTask</code>。这里我没有实现<code>Runnable</code>，而是直接在<code>MyTimerTask</code>持有一个<code>Runnable</code>。所以<code>MyTimerTask</code>里就存在了两个参数，<code>Runnable task</code>和<code>long time</code>；接着就是必备着带着两个参数的构造方法。<strong>记住，既然自己实现了任务类就得自己实现一个比较器</strong>。</li>
<li>接着就是在<code>MyTimer</code>定时器里创建一个优先级队列<code>PriorityQueue&lt;MyTimerTask&gt; queue</code>，存放就是自己写的任务类<code>MyTimerTask</code>。再然后就是创建<code>schedule</code>方法，在这个方法里实例化一个<strong>新任务</strong>并放入队列中。</li>
<li>随后就是在<code>MyTimer</code>中通过构造方法创建线程，循环从队列中提取任务进行执行</li>
</ul>
<p>这三部分我都是按照上面说的，只是将第二步与第三步放在了一起。然后是注意线程安全问题，注意加锁 <code>synchronized</code> 以及<code>wait/notify</code>；</p>
<pre><code>package timer;

import java.util.PriorityQueue;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: Syrena.
 * Date: 2026-09-20
 * Time: 13:24
 */
public class MyTimer {
    // 队列里的每一个元素都是MyTimerTask类型
    private PriorityQueue&lt;MyTimerTask&gt; queue = new PriorityQueue&lt;&gt;();
    private Object locker = new Object();
    // 创建schedule方法
    public void schedule(Runnable task, long delay) {
        synchronized (locker) {
            // 记录时间戳
            // 实例化时建议放在锁里，否则没拿到锁的时候就先实例化时间存在一定延迟
            MyTimerTask timerTask = new MyTimerTask(task, System.currentTimeMillis() + delay);
            queue.offer(timerTask);
            locker.notify();
        }
    }

    // 构造方法中，额外创建一个线程，负责执行队列中的任务
    public MyTimer() {
        Thread t = new Thread(() -&gt; {
            try {
                while (true) {
                    synchronized (locker) {
                        while (queue.isEmpty()) {
                            locker.wait();
                        }
                        // 取出队首元素
                        MyTimerTask task = queue.peek();    // 先判断时间是否到再移除
                        if(System.currentTimeMillis() &lt; task.getTime()) {
                            // 当前任务时间如果比系统时刻还大，说明还未到执行时刻
                            locker.wait(task.getTime()
                                    - System.currentTimeMillis());
                        } else {
                            task.run();
                            queue.poll();
                        }
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();
    }
}

</code></pre>
<pre><code>package timer;

/**
 * Created with IntelliJ IDEA.
 * Description: 用来描述一个任务
 * User: Syrena.
 * Date: 2026-09-20
 * Time: 13:15
 */

    /*
    两个方法：
    1. 实现一个Runnable，但需要基于抽象类定义MyTimerTask
    2. 持有一个Runnable
     */
// 由于比较的类是自己写了类型，所以比较规则需要自己写（Comparable/Comparator）
public class MyTimerTask implements Comparable&lt;MyTimerTask&gt;{

    private Runnable task;
    private long time;  // 记录要执行的时刻，用时间戳

    // 构造函数里传入可运行的task
    public MyTimerTask (Runnable task,long time) {
        this.task = task;
        this.time = time;
    }

    @Override
    public int compareTo(MyTimerTask o) {
        return (int) (this.time - o.time);  // &lt; 0，则this &lt; 0；需求是让时间小的元素在队首，实现小根堆
    }
    public long getTime() {
        return this.time;
    }
    public void run() {
        task.run();
    }

    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Hello 1000");
            }
        },1000);
        myTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Hello 2000");
            }
        },2000);
        myTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Hello 3000");
            }
        },3000);
    }
}
</code></pre>
<p>以上就是定时器的模拟实现，需要注意的是：</p>
<pre><code>public MyTimer() {
    Thread t = new Thread(() -&gt; {
        try {
            while (true) {
                synchronized (locker) {
                    while (queue.isEmpty()) {
                        locker.wait();
                    }
                    // 取出队首元素
                    MyTimerTask task = queue.peek();    // 先判断时间是否到再移除
                    if(System.currentTimeMillis() &lt; task.getTime()) {
                        // 当前任务时间如果比系统时刻还大，说明还未到执行时刻
                        locker.wait(task.getTime()
                                - System.currentTimeMillis());
                    } else {
                        task.run();
                        queue.poll();
                    }
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t.start();
}
</code></pre>
<p>此处的构造方法本身可以写<code>synchronized</code>，但此处不能将<code>synchronized</code>直接包裹整个构造方法，因为此处的<code>synchronized</code> 保护的是线程的 <code>run</code> 里面的 <code>lambda</code>；</p>
<pre><code>while (queue.isEmpty()) {
    locker.wait();
}
</code></pre>
<p>此处使用 <code>wait</code> 时要使用 <code>while</code>，避免异常唤醒以及其他可能唤醒能够循环再次检查一遍；如果使用<code>if</code>检查的话，会导致忙等，一直循环检查会消耗大量 CPU 资源。</p>
<pre><code>if(System.currentTimeMillis() &lt; task.getTime()) {
    // 当前任务时间如果比系统时刻还大，说明还未到执行时刻
    locker.wait(task.getTime()
            - System.currentTimeMillis());
} else {
    task.run();
    queue.poll();
}
</code></pre>
<p>此处使用 <code>wait</code> 时无需再 <code>while</code> 检查，因为外面有一层 <code>while</code>，就算这里被异常唤醒后能够再循环到这一步重新 <code>wait</code>。</p>
<p>标准库提供的 Timer 和自己写的 MyTimer 差不多，都是使用一个线程，负责扫描队首元素并执行。但其实可以结合线程池，创建多个线程，负责执行这里的队列中的任务（一个线程负责扫描，扫描到需要执行的任务，添加到另一个线程池的任务队列中，有多个线程负责执行）</p>
<p>如果任务少/任务的时间分散都无所谓，如果任务特别多，时间非常集中，一个线程就可能执行不过来~</p>
<p>定时器其实是一个非常重要的组件，在分布式系统中，把定时器专门提取出来，封装成一个单独的服务器~</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 多线程基础：Thread、线程安全与同步机制</title>
    <link href="https://blog.syrena.cn/posts/javaee-multithreading/javaee-multithreading/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-multithreading/javaee-multithreading/</id>
    <published>2026-09-10T00:00:00.000Z</published>
    <updated>2026-09-10T00:00:00.000Z</updated>
    <summary>理解Java线程的创建、生命周期、线程安全、同步机制、锁、线程池以及并发工具的基本使用.</summary>
    <content type="html"><![CDATA[<h2>1.初识线程 <code>Thread</code></h2>
<h3>1.1 什么是多线程</h3>
<p>上一篇我们已经提到，一个程序运行起来就是一个<strong>进程</strong>。而一个进程里，又存在多个<strong>线程</strong>。</p>
<p>简单来说：</p>
<blockquote>
<p>进程提供运行环境和资源，而线程是真正在这个环境中执行代码的执行流；</p>
</blockquote>
<p>我们之前是这么比喻的：</p>
<blockquote>
<p>进程 = 一套房子</p>
<p>线程 = 房子里干活的人</p>
</blockquote>
<p>同一个房子里，可以住好几个人，就比如：</p>
<blockquote>
<p>张三在厨房做饭<br />
李四在客厅扫地<br />
王五在阳台晾衣服</p>
</blockquote>
<p>他们虽然在干不同的事情，但他们都在共享着一套房子里的资源。当然，在程序里也差不多。</p>
<p>在一个 Java 进程中可以存在多个线程，每个线程都有自己的执行流程，但是同一个进程里的线程又可以共享这个进程里的资源和数据。这就是所说的<strong>多线程</strong>。</p>
<h3>1.2 为什么需要线程</h3>
<p>还是拿上一篇“吃鸡”的例子</p>
<p>现在桌子上有 100 只鸡：</p>
<p>如果只有我一个人吃：</p>
<blockquote>
<p>我必须得一只、两只、三只......</p>
</blockquote>
<p><img src="./thread0.png" alt="" /></p>
<p>但如果我叫来一位老铁：</p>
<blockquote>
<p>如果条件合适，两个人就可以分别处理一部分工作</p>
</blockquote>
<p><img src="./thread1.png" alt="" /></p>
<p>对应到程序里就是一个任务可以被拆成很多个相对独立的任务，交给多个线程执行。</p>
<p>但，千万不要理解成：<strong>线程越多，程序就一定越快</strong>。这个结论当然是不成立的。</p>
<p>依旧是吃鸡这个例子，一张桌子本来坐四个人正好，你非得找 100 个人围着这张桌子：</p>
<blockquote>
<p>“兄弟们！我们今天靠人数优势干爆这 100 只鸡！”</p>
</blockquote>
<p>最后可能会出现这种情况：</p>
<blockquote>
<p>鸡没吃几只，100 个人先因为 “你挡我了。”、“筷子给我。”、 “让我过去。”等各种原因先打起来。</p>
</blockquote>
<p>线程也是一样。线程创建、切换和调度本省都有开销，而且 CPU 核心数量也是有限的。所以合理的多线程可以提高效率，而不是无限增加线程数量。</p>
<p>这里我给出了一段代码，可以尝试运行一下，比较线程串行和并行的时间效率：</p>
<pre><code>/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: Sirens007
 * Date: 2026-09-02
 * Time: 10:27
 */
public class TheadAdvantage {
    private static final long count = 10_0000_0000;

    private static void concurrency() throws InterruptedException {
        long begin = System.nanoTime();

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                int a = 0;
                for (long i = 0; i &lt; count; i++) {
                    a--;
                }
            }
        });
        // 主线程内计算b的值
        int b = 0;
        for (long j = 0; j &lt; count; j++) {
            b--;
        }
        // 等待thread进程结束
        thread.join();

        long end = System.nanoTime();
        double ms = (end - begin) * 1.0 /1000 /1000;
        System.out.printf("并发：%f 毫秒 %n",ms);
    }

    private static void serial() {
        // 都在主线程计算a , b值
        long begin = System.nanoTime();
        int a = 0;
        for (long i = 0; i &lt; count; i++) {
            a--;
        }
        int b = 0;
        for (long j = 0; j &lt; count; j++) {
            b--;
        }
        long end = System.nanoTime();
        double ms = (end - begin) * 1.0 /1000 /1000;
        System.out.printf("串行：%f 毫秒 %n",ms);
    }

    public static void main(String[] args) {
        try {
            concurrency();
            serial();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

    }
}
</code></pre>
<p><img src="./thread2.png" alt="" /></p>
<h2>2.Java 中创建线程</h2>
<p>我了解到的是有三个方法：</p>
<h3>2.1 继承 <code>Thread</code></h3>
<p>首先需要自己写一个类，主要是为了重写 <code>Thread</code> 中的 <code>run()</code>方法：</p>
<pre><code>class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("我开始干活了");
    }
}
</code></pre>
<p>其中的 <code>run()</code>方法描述的是这个线程启动后，到底要做什么，然后就会创建线程对象 <code>thread</code>。</p>
<p>但是此时还没有真正开始执行，你可以类比这个例子：</p>
<blockquote>
<p>我把李四叫到了公司</p>
<p>李四就说：“老板，我来了。”</p>
<p>我：“嗯”</p>
</blockquote>
<p>然后谁也不说话，李四当然不会知道自己需要去做什么了。</p>
<p>所以这个时候还需要我们去启动线程：<code>thread.start();</code></p>
<p>这就相当于：我跟李四说：“李四，开始干活！ ”</p>
<p>这个时候就算是真正的启动了一个线程。<strong>注意：</strong><code>run()</code> 和 <code>start()</code>千万不要弄混淆；直接写 <code>thread.run()</code>的话只是相当于直接调用方法，而启动执行流才是使用：<code>thread.start()</code></p>
<h3>2.2 实现 <code>Runnable</code> 接口</h3>
<p>除了直接继承<code>Thread</code>，Java 还可以把“线程”和“任务”拆开。</p>
<p>同样我们还是需要重写 run()方法，因为实现 Runnable 接口就得重写它的所有方法：</p>
<p>不过 Runnable 只有一个 run 方法~</p>
<p><img src="./thread3.png" alt="" /></p>
<pre><code>class MyRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("开始搬砖");
    }
}
</code></pre>
<p>然后还要记得启用线程：</p>
<pre><code>MyRunnable task = new MyRunnable();
Thread thread = new Thread(task);
thread.start();
</code></pre>
<p>不知道会不会有人觉得这样写法比继承 Thread 多绕了一层...</p>
<p>不过我推荐这样理解：</p>
<blockquote>
<p>Thread 是员工，Runnable 是工作任务；</p>
<p>假如当前有个任务是搬 100 块砖，因此我需要去定义这个任务</p>
</blockquote>
<p><code>MyRunnable task = new MyRunnable()</code>，然后再把任务交给员工，<code>Thread thread = new Thread(task)</code>，最后老板说开始干，也就是<code>thread.start()</code>这样就会好理解多了；</p>
<h3>2.3 其他变形</h3>
<h4>2.3.1 使用匿名内部类创建 <code>Thread</code></h4>
<p>如果你打算这个线程就使用一次的话，单独写 MyThread 就似乎有点麻烦了。</p>
<p>所以我们可以直接使用匿名内部类：</p>
<pre><code>Thread thread = new Thread() {

    @Override
    public void run() {
        System.out.println("我开始干活了");
    }
};

thread.start();
</code></pre>
<p>本质上还是要重写 run 方法，只是这次这个类我没有单独取名字，直接现场创建。</p>
<h4>2.3.2 使用匿名内部类创建 <code>Runnable</code></h4>
<p>既然上面 Thread 可以使用匿名内部类，Runnable 当然也是可以啦</p>
<pre><code>Thread thread = new Thread(new Runnable() {

    @Override
    public void run() {
        System.out.println("我开始干活了");
    }
});

thread.start();
</code></pre>
<p>本质上还是：任务 + 执行任务的线程</p>
<h4>2.3.3 使用 <code>Lambda</code> 创建线程</h4>
<p>首先我们需要知道什么是 Lambda，Lambda 你可以理解为：<strong>对“只有一个抽象方法的接口”的简化写法。</strong></p>
<p>Lambda 是从 Java8 开始引入的，主要就是为了“把一段代码装起来”，举个例子：</p>
<pre><code>public static void hello() {
    System.out.println("Hello");
}
</code></pre>
<p>这个方法就描述了一件事情，但我现在想把这段“要执行的代码”交给另外一个东西，让它以后帮我们执行，我们一般就只能通过 <strong>接口 -&gt; 实现类 -&gt; 重写方法 -&gt; 创建对象</strong> 这样的步骤完成；</p>
<pre><code>// 创建接口
interface Task {
    void run();
}

// 创建类实现接口
class MyTask implements Task {

    @Override
    public void run() {
        System.out.println("Hello");
    }
}

// 最后创建对象
Task task = new MyTask();
</code></pre>
<p>引入 lambda 就是为了让这种代码变的简洁,于是这部分就变成了：</p>
<pre><code>Task task = () -&gt; {
    System.out.println("Hello");
};
</code></pre>
<p>能够省去了“创建类实现接口”这个部分；</p>
<h2>3.<code>Thread</code> 常用属性和方法</h2>
<p>至于线程我们已经知道如何创建出来了，那么我们就还得了解它现在在干嘛、怎么让它停下来、等待它结束，或者通知它停止？</p>
<h3>3.1 <code>Thread</code> 常见属性</h3>
<p>在我们创建线程的时候，thread 不只是一个普通的对象，它描述的就是后面那个线程执行流。</p>
<p>先来一段代码：</p>
<pre><code>public class ThreadDemo {

    public static void main(String[] args) {

        Thread thread = new Thread(() -&gt; {
            System.out.println("线程开始工作");
        });

        System.out.println("线程 ID：" + thread.getId());
        System.out.println("线程名称：" + thread.getName());
        System.out.println("线程状态：" + thread.getState());
        System.out.println("线程优先级：" + thread.getPriority());
        System.out.println("是否后台线程：" + thread.isDaemon());
        System.out.println("是否存活：" + thread.isAlive());
        System.out.println("是否被中断：" + thread.isInterrupted());
    }
}
</code></pre>
<p>这里面用到的方法就是 Thread 提供的线程 ID、名称、状态、优先级、是否存活以及终端标志等信息。</p>
<ul>
<li>getId()：获取线程的编号，就类似员工的工号；</li>
<li>getName()：获取线程名字，我们当然可以自定义名字，方法如下；</li>
<li>getState()：用于获取线程当前的状态，后面会再介绍进程的四个状态；</li>
<li>getPriority()：用于查看线程优先级，但这里的线程优先级高也不代表一定会先执行；</li>
<li>isDaemon()：用于查看是不是后台线程，也可以叫守护线程，你可以理解为做后勤的；然后一般前台线程、后台线程都是有多个的；如果有多个<strong>前台线程</strong>，必须得等所有<strong>前台线程</strong>都结束了，<strong>进程</strong>才能结束；</li>
<li>isAlive()：用来判断该线程是否还活着；</li>
<li>isInterrupted()：给线程发一个中断通知，但不是强制杀死线程；</li>
</ul>
<pre><code>// 自定义名字
Thread thread = new Thread(() -&gt; {
    System.out.println("开始搬砖");
}, "李四");
</code></pre>
<h3>3.2 <code>Thread</code> 常见构造方法</h3>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Thread()</code></td>
<td>创建线程对象</td>
</tr>
<tr>
<td><code>Thread(Runnable target)</code></td>
<td>使用 <code>Runnable</code>&lt;br/&gt; 对象创建线程对象</td>
</tr>
<tr>
<td><code>Thread(String name)</code></td>
<td>创建线程对象，并命名</td>
</tr>
<tr>
<td><code>Thread(Runnable target, String name)</code></td>
<td>使用 <code>Runnable</code>&lt;br/&gt; 对象创建线程对象，并命名</td>
</tr>
<tr>
<td>【了解】<code>Thread(ThreadGroup group, Runnable target)</code></td>
<td>线程可以被用来分组管理，分好的组即为线程组，这个目前我们了解即可</td>
</tr>
</tbody>
</table>
<p>具体的使用例子如下：</p>
<pre><code>Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");
</code></pre>
<p>如果我们想实时了解线程的状态，我们还可以通过 jdk 里的 jconsole 这个可执行文件查看，路径就在 jdk 下面的 bin 目录里，如下：</p>
<p><img src="./thread4.png" alt="" /></p>
<h3>3.3 启动一个线程-<code>start()</code></h3>
<p>我们之前说过<code>run()</code> 描述线程要做的事情，而真正让线程独立运行的是 <code>start()</code>；只有调用 <code>start()</code>，底层才会真正创建新的线程。</p>
<p>所以我们要了解 start()我们就得了解这个方法：</p>
<p><img src="./thread5.png" alt="" /></p>
<p><img src="./thread6.png" alt="" /></p>
<p>会发现真正底层启动线程的工作是由 start0 这个方法完成的。从 native 可以看出这是一个本地方法或者说是Java 标准库/JVM 提供的方法，由 C++ 语言编写的方法。本质上是调用系统的 API，不过这对于我们学习 Java 目前应该不是那么必要。</p>
<h3>3.4 中断一个线程</h3>
<p>假设我们面对一个情境：</p>
<p>前面已经把员工成功招进公司，也知道怎么查看他的名字、状态这些信息了，但现实中老板安排下去的任务也不一定非得执行到底。</p>
<p>比如现在李四正在疯狂转账：</p>
<blockquote>
<p>李四：老板放心，钱马上就转过去了。</p>
</blockquote>
<p>结果老板突然接到电话：</p>
<blockquote>
<p>“别转了！对面是骗子！”</p>
</blockquote>
<p>那现在就有一个问题：</p>
<p><strong>李四这个线程都已经开始执行了，我该怎么通知他赶紧停下来？</strong></p>
<p>当然，Java 不是粗暴将它 thread.kill()掉，而是采用通知的方式告诉他你该停了。此时线程就会收到通知，但是否停止、什么时候停止就由线程的代码自己决定。</p>
<p>我学习到的常见就是以下两种方法：</p>
<ol>
<li>通过共享的标记来进行沟通；</li>
<li>调用 interrupt()方法通知；</li>
</ol>
<p><strong>示例一：共享变量作为标志位（标志位要加上 volatile）</strong></p>
<pre><code>class MyRunnable implements Runnable {

    public volatile boolean isQuit = false;

    @Override
    public void run() {
        while (!isQuit) {
            System.out.println(
                    Thread.currentThread().getName()
                    + "：别管我，我忙着转账呢！"
            );
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(
                Thread.currentThread().getName()
                + "：啊！险些误了大事"
        );
    }
}
public static void main(String[] args)
        throws InterruptedException {
    MyRunnable target = new MyRunnable();
    Thread thread = new Thread(target, "李四");
    System.out.println("让李四开始转账");

    thread.start();
    Thread.sleep(10 * 1000);
    System.out.println("老板来电话了，对方是骗子！");
    target.isQuit = true;
}
</code></pre>
<p>此处的 volatile 关键字我们后面再继续专门讲解，这里先不用钻太深~</p>
<p>现在可以先简记为：<code>volatile isQuit</code>就是为了让一个线程修改以后，另外一个线程能够及时看到。</p>
<p>上面的代码流程如下：</p>
<p><img src="./thread7.png" alt="" /></p>
<p>共享变量此处再补充一个点：</p>
<p>如该例子：（我也不知道为什么截 IDEA 里就是这么糊）</p>
<p><img src="./thread8.png" alt="" /></p>
<p>Java 要求 Lambda 捕获的局部变量必须是：</p>
<blockquote>
<p>final 或者 effectively final（实际没有被修改）</p>
</blockquote>
<p>这里是触发“变量捕获”这样的语法，Lambda 是回调函数，执行时机是很久之后（操作系统真正创建出线程知乎偶，才会执行的）</p>
<p>所以很有可能，后续线程创建好了，当前 <code>main</code> 这里的方法<strong>都执行完了</strong>对应的 isFinished 就销毁了。所以为了解决上述的问题：</p>
<blockquote>
<p>Java 将被捕获的变量给拷贝一份，拷贝给 Lambda 里面。外面的变量是否销毁，就不影响 Lambda 里面的执行了。</p>
</blockquote>
<p>但是拷贝，就意味着这样的变量不适合进行修改。因为修改一方，另一方不会随之变化的（本质上是两个变量），所以 Java 语言的大佬就不允许在这里进行修改~</p>
<p>上面图片中可以利用<strong>成员变量</strong>（也就是定义在类里面、方法外面的变量，它属于对象或类）</p>
<pre><code>public class Demo5 {
    private static boolean isFinished = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -&gt; {
            while (!isFinished) {
                System.out.println("Hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    //somefunc...
                }
            }
            System.out.println("t 结束");
        });

        t.start();
        Thread.sleep(3000);
        System.out.println("main 线程尝试终止 t 线程");
        isFinished = true;
    }
}
</code></pre>
<p>但这里有一个隐患，就是线程之间可能看不到最新值，也就是存在可见性问题，涉及到 <code>volatile</code>，我们先保留该问题；但主要还是应为 CPU 缓存机制</p>
<p><strong>示例二：使用</strong><code>interrupt</code><strong>通知线程</strong></p>
<pre><code>class MyRunnable implements Runnable {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            System.out.println(
                    Thread.currentThread().getName()
                    + "：别管我，我忙着转账呢！"
            );
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println(
                        Thread.currentThread().getName()
                        + "：收到老板的中断通知！"
                );

                // sleep 被 interrupt 后，中断标志会被清除
                // 因此这里直接 break 结束循环
                break;
            }
        }
        System.out.println(
                Thread.currentThread().getName()
                + "：啊！险些误了大事"
        );
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable target = new MyRunnable();
        Thread thread = new Thread(target, "李四");
        System.out.println("让李四开始转账");
        thread.start();
        
        // 老板 10 秒后发现被骗
        Thread.sleep(10 * 1000);
        System.out.println("老板来电话了，对方是骗子！");

        // 通知李四线程停止
        thread.interrupt();
    }
}
</code></pre>
<p><code>Interrupt</code>你可以先理解为老板让你先停一下，而不是让你人间蒸发。当然啦，老板的话听不听也可以取决于你~</p>
<p><code>interrupt()</code> 的核心是通知线程发生了中断，而不是直接强制终止线程。</p>
<p>既然说到了 <code>interrupt()</code>那其实就还有一种方法，<strong>就是</strong><code>Thread.interrupted()</code><strong>也能判断</strong></p>
<pre><code>while (!Thread.interrupted()) {

}
</code></pre>
<p>它也可以用来检查<strong>当前线程</strong>的中断状态。注意是当前线程，因为<code>Thread.interrupted()</code>是静态方法。不过我们还要了解一个方法，就是 <code>thread.isInterrupted()</code>，它们存在一定区别。</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>是否静态</th>
<th>检查谁</th>
<th>会不会清除中断标志</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Thread.interrupted()</code></td>
<td>是</td>
<td>当前线程</td>
<td><strong>会清除</strong></td>
</tr>
<tr>
<td><code>thread.isInterrupted()</code></td>
<td>否</td>
<td>指定的 <code>thread</code>&lt;br/&gt; 对象</td>
<td><strong>不会清除</strong></td>
</tr>
</tbody>
</table>
<p>如果在线程自己的代码中判断中断状态，经常会写成 <code>Thread.currentThread().isInterrupted()</code>。其中 <code>Thread.currentThread()</code> 获取的是当前正在执行这段代码的线程，然后再通过 <code>isInterrupted()</code> 查看它的中断状态。它们有个区别需要记一下：</p>
<p><code>thread.isInterrupted()</code>读取中断标志后不会清除这个标志；</p>
<p><code>Thread.interrupted()</code>读取中断标志后会清除这个标志；</p>
<p>就拿下面代码举个例子：</p>
<pre><code>public class Demo4 {
    public static void main(String[] args) {
        Thread.currentThread().interrupt();
        System.out.println(Thread.currentThread().getName() + "+" + Thread.interrupted());
        System.out.println(Thread.currentThread().getName() + "+" + Thread.interrupted());
    }
}
// 得到结果
// main+true
// main+false
</code></pre>
<p><img src="./thread9.png" alt="" /></p>
<p><code>Thread.interrupted()</code> 在获取当前线程中断状态的同时，还会将中断状态清除。因此第一次调用得到 <code>true</code> 后，如果中途没有再次调用 <code>interrupt()</code>，第二次调用就会得到 <code>false</code>。</p>
<pre><code>public class Demo4 {
    public static void main(String[] args) {
        Thread.currentThread().interrupt();
        System.out.println(Thread.currentThread().getName() + "+" + Thread.currentThread().isInterrupted());
        System.out.println(Thread.currentThread().getName() + "+" + Thread.currentThread().isInterrupted());
    }
}
// 得到结果
// main+true
// main+true
</code></pre>
<p><img src="./thread10.png" alt="" /></p>
<p>而这里的 <code>interrupted</code> 是读取线程的状态而不是去修改（刚好也出现了后面要说的 <code>volatile</code>）</p>
<p><strong>如果线程正好在</strong> <code>sleep()</code>：</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>public void interrupt()</code></td>
<td><code>thread.interrupt()</code>：向指定线程发出中断请求</td>
</tr>
<tr>
<td><code>public static boolean interrupted()</code></td>
<td><code>Thread.interrupted()</code>：检查<strong>当前线程</strong>的中断状态，并<strong>清除</strong>中断状态</td>
</tr>
<tr>
<td><code>public boolean isInterrupted()</code></td>
<td><code>thread.isInterrupted()</code>：检查<strong>指定线程对象</strong>的中断状态，<strong>不会清除</strong></td>
</tr>
</tbody>
</table>
<p>到这的话就有点意思了，假设李四正在干活：</p>
<pre><code>while (!Thread.currentThread().isInterrupted()) {
    System.out.println("工作");
    Thread.sleep(1000);
}
</code></pre>
<p>此时老板打来个电话，<code>thread.interrupt()</code></p>
<p>刚好李四又在睡觉<code>sleep()</code>，<code>Thread.sleep()</code> 本身声明了可能抛出 <code>InterruptedException</code>。如果线程正在 <code>sleep()</code> 的过程中，其他线程对它调用了 <code>interrupt()</code>,李四这线程就会被唤醒并抛出<code>InterruptedException</code>；此时被 catch 到了，那么接下来需要干什么就取决于 catch 里的内容了，可以跳出也可以继续执行；</p>
<pre><code>public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()-&gt;{
            while(!Thread.currentThread().isInterrupted()) {
                System.out.println("Hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    /*throw new RuntimeException(e);*/
                    /*Thread.currentThread().interrupt();*/
                }
            }
            System.out.println("t 结束");
        });
        t.start();

        Thread.sleep(3000);
        System.out.println("main 线程尝试终止 t 线程");
        t.interrupt();
    }
}
</code></pre>
<p>因为<strong>收到 <strong><code>InterruptedException</code></strong> 并不代表 Java 自动决定这个线程必须结束。</strong></p>
<p>对于这部分解释，正常来说，调用 <code>Interrupt</code> 方法就会修改 <code>isInterrupted</code> 方法内部的标志位（设置为 <code>true</code>），由于上述代码中，是把 <code>sleep</code> 给唤醒了，这种提前唤醒的情况下，<code>sleep</code> 就会在唤醒之后，把 <code>isInterrupted</code> 标志位给设置回 <code>false</code>。</p>
<p>所以在这样的情况下，如果继续执行到循环的条件判定，就会发在能够继续执行~</p>
<h3>3.5 等待一个线程 - <code>join()</code></h3>
<p>我们前面知道了如何让线程暂停，但现实中还有另一种情况：</p>
<blockquote>
<p>就是我的工作必须等你的工作做完以后才能继续</p>
</blockquote>
<p>假设李四负责：</p>
<blockquote>
<p>整理今天公司的转账记录；</p>
</blockquote>
<p>而我负责：</p>
<blockquote>
<p>根据李四整理出来的数据统计今天到底赚了多少钱；</p>
</blockquote>
<p>那我肯定不能李四刚开始整理，我这边就直接开始统计。为了解决这种情况，Java 中就引入了<code>join()</code>，来看个例子：</p>
<pre><code>public class Demo {
    public static void main(String[] args)
            throws InterruptedException {
        Thread t1 = new Thread(() -&gt; {
            System.out.println("李四开始整理数据");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("李四整理完成");
        }, "李四");

        t1.start();
        t1.join();
        System.out.println("main：开始统计数据");
    }
}
</code></pre>
<p>注意哈这地方容易弄反，在主线程中调用 <code>join</code> 得用到 <code>t1</code> 对象；也就是说调用<code>join()</code>的那个线程才是真正等待的。</p>
<blockquote>
<p>main 等待 t 线程  &lt;-----&gt;  t.join()</p>
</blockquote>
<p>说实话，这里主要是按正常语言逻辑不太符合主谓宾习惯，但我们可以反过来理解</p>
<blockquote>
<p>t 线程合并入 main（合并前提，必然是 main 存在）</p>
</blockquote>
<p>两个执行流合并为一个，一个执行流结束，另一个继续执行；</p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>public void join()</code></td>
<td>等待线程结束</td>
</tr>
<tr>
<td><code>public void join(long millis)</code></td>
<td>等待线程结束，最多等 <code>millis</code> 毫秒</td>
</tr>
<tr>
<td><code>public void join(long millis, int nanos)</code></td>
<td>同理，但可以更高精度</td>
</tr>
</tbody>
</table>
<p>join 提供了带参数的版本，就是指定“超时时间”即等待的最大时间~</p>
<p>就像是你约了女神上午 8 点出来，最后女神 12 点才到；你继续在这里傻等就是“感动自己”~</p>
<h3>3.6 获取、休眠当前线程</h3>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>public static Thread currentThread();</code></td>
<td>返回当前线程对象的引用</td>
</tr>
</tbody>
</table>
<p>很明显这是个静态方法，也就是说哪个线程调用这个方法，返回哪个线程的引用，类似 <code>this</code>；</p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>public static void sleep(long millis) throws InterruptedException</code></td>
<td>休眠当前线程 millis 毫秒</td>
</tr>
<tr>
<td><code>public static void sleep(long millis, int nanos) throws InterruptedException</code></td>
<td>可以更高精度的休眠</td>
</tr>
</tbody>
</table>
<p>Thread.sleep()是让调用它的当前线程休眠；</p>
<p>从上面多个例子你会发现，实例中往往都有</p>
<pre><code>throws InterruptedException

// 或者以下

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
}
</code></pre>
<p>原因还是之前说的，线程睡眠期间，可能被其他线程通过 <code>interrupt</code> 方法打断提前醒来，它和 sleep、join、wait 这三个方法也一样密切；</p>
<p>注意:</p>
<p>sleep(0)不是不休眠，而是意味着让当前的线程立即放弃 CPU 资源，等待操作系统重新调度~</p>
<h2>4.线程状态</h2>
<p>Java 中一共有 6 种状态：</p>
<table>
<thead>
<tr>
<th><strong>状态</strong></th>
<th><strong>含义</strong></th>
<th><strong>比喻</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>NEW</strong></td>
<td>新建状态</td>
<td>安排了工作，还未开始</td>
</tr>
<tr>
<td><strong>RUNNABLE</strong></td>
<td>可运行状态</td>
<td>可工作的，又可以分成正在工作中和即将开始工作</td>
</tr>
<tr>
<td><strong>BLOCKED</strong></td>
<td>阻塞状态</td>
<td>会议室有人使用，只能排队等</td>
</tr>
<tr>
<td><strong>WAITING</strong></td>
<td>无限等待状态</td>
<td>等待老板通知，没有时间限制</td>
</tr>
<tr>
<td><strong>TIMED_WAITING</strong></td>
<td>有时间限制的等待状态</td>
<td>午休30分钟，到时间自动回来</td>
</tr>
<tr>
<td><strong>TERMINATED</strong></td>
<td>终止状态</td>
<td>工作完成了</td>
</tr>
</tbody>
</table>
<p>不过要注意：RUNNABLE <strong>不完全说是线程一定正在运行</strong>；Java 中把正在运行和等待 CPU 调度都归到 RUNNABLE 状态；提到这我们可以联系到之前关于操作系统的<strong>进程</strong>的两个状态：<strong>就绪状态</strong>和<strong>阻塞状态</strong>；</p>
<h3>4.1 线程状态的转换</h3>
<p><code>NEW</code> 状态其实就是 new 了 Thread 对象，还没有 start()</p>
<p><code>TERMINATED</code> 状态是线程已经做完了他该做的了，内核中的线程已经结束，但 Thread 对象还在；</p>
<p><code>RUNNABLE</code> 状态就是刚刚提到的，可工作，有可分成正在工作中和即将开始工作；</p>
<p><code>TIMED_WAITTING</code> 状态描述的是指定时间的阻塞，当等待时间结束后，线程会自动恢复到可运行状态，重新参与 CPU 调度；也就是：</p>
<ul>
<li>有明确的等待时间</li>
<li>等待时间到了以后，线程不会一直停留</li>
<li>线程是否立即执行取决于 CPU 调度</li>
</ul>
<p><code>WAITING</code> 状态表示无限等待状态，例子有<code>thread.join()</code>，会一直等待，等到天荒地老；</p>
<p><code>BLOCKED</code> 状态表示阻塞状态，在我们后面继续了解锁的时候会重点遇到；</p>
<p><img src="./thread11.png" alt="" /></p>
<p>NEW --&gt; RUNNABLE --&gt; TIMED_WAITING --&gt; TERMINATED 转换如下：</p>
<pre><code>public class Demo6 {

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -&gt; {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        System.out.println(thread.getState());
        thread.start();
        System.out.println(thread.getState());
        Thread.sleep(1000);
        System.out.println(thread.getState());
        thread.join();
        System.out.println(thread.getState());
    }
}

// 输出结构：
// NEW
// RUNNABLE
// TIMED_WAITING
// TERMINATED
</code></pre>
<p>对于上面的结果，解释 <code>RUNNABLE</code>、<code>TIMED_WAITING</code>：</p>
<p>在第二个输出状态时，主线程由于本身就在运行，在创建新的线程时会需要进过调度，所以通常运行上述代码都是 <code>main</code> 先跑到第二个状态打印，虽然此时 <code>thread</code> 线程已经启动，但还没进入 <code>sleep</code>(3000)，这也是为什么后面还需要 <code>main</code> 线程先 <code>sleep</code>(1000)的原因；</p>
<p>而 WAITING 状态就如下演示：</p>
<pre><code>public class Demo6 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -&gt; {
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"t1");
        Thread t2 = new Thread(() -&gt; {
            try {
                Thread.sleep(1000);
                t1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"t2");

        t2.start();
        t1.start();
    }
}
</code></pre>
<p>如果让 t2 自己打印自己的状态，就会受到执行时机影响，所以通常用外部工具观察线程状态，如下：</p>
<p><img src="./thread12.png" alt="" /></p>
<h3>4.2 多线程带来的问题</h3>
<p>直接先抛出一例子：</p>
<pre><code>public class Demo7 {
    public static int count;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -&gt; {
            for (int i = 0; i &lt; 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -&gt; {
            for (int i = 0; i &lt; 50000; i++) {
                count++;
            }
        });


            t1.start();
            t2.start();
            t1.join();
            t2.join();
        System.out.println(count);
    }
}
</code></pre>
<blockquote>
<p>写 join 的原因是避免 main 线程先执行完，所以应该先等到两个线程并入 main 线程后再执行打印</p>
</blockquote>
<p>执行后会发现，count 结果是一个随机数，而且还是随机小于 100000 的数字；像这样导致结果和预期不符合的就属于 BUG，需要修复，这也是多线程并发执行容易引起的安全问题；</p>
<p>深入一下底层会发现：<code>count++</code>看似是一行代码，实际上对应到的是 3 个 CPU 指令</p>
<ol>
<li>load：将内存中 count 的值加载到 CPU 寄存器</li>
<li>add：把寄存器中的内容 +1</li>
<li>save：把寄存器中的内容保存回内存上</li>
</ol>
<p>并且现代计算机操作系统都是<strong>抢占式调度</strong>，意思就是：</p>
<blockquote>
<p>即使一个进程（或线程）还没有执行完，操作系统也可以强制暂停它，把 CPU 分配给其他进程。</p>
</blockquote>
<p>也就说明，上面的代码中执行这三个步骤会是随机的、不可预期的</p>
<p><img src="./thread13.png" alt="" /></p>
<p>左侧是我们所预期的，但实际会是右边这种，当然这样的情况有很多种；</p>
<p>右侧的操作过程描述：</p>
<blockquote>
<p>假设当前内存上的值为 0；</p>
<p>t1 线程会先去读取内存的 0 并加载到当前寄存器上，也就是拷贝一份</p>
<p>随后 t2 也进行了同样的操作，但它先完成了 add 并最终存入内存中，此时内存的值为 1</p>
<p>但此时 t1 线程还是在之前加载的 0 上面进行操作运算，最后得到值为 1 并覆盖掉 t2 线程得到的 1 放在内存上</p>
</blockquote>
<p>所以在上面的代码 5w 次循环过程中，中间会发生很多类似的操作，最终的值一定是 &lt;=10w；</p>
<p>对于发生线程安全问题通常有以下：</p>
<ol>
<li><strong>操作系统对于线程的调度是随机的，也就是操作系统抢占式【根本原因】；</strong></li>
<li><strong>多个线程同时修改同一个变量；</strong></li>
<li><strong>修改操作，不是原子；</strong></li>
<li><strong>内存可见性问题；</strong></li>
<li><strong>指令重排序；</strong></li>
</ol>
<p>前两个问题我们已经讨论出，我们具体讨论 2 和 3 ，4 和 5 留到后面</p>
<p><strong>问题 2：</strong></p>
<p>对于多个线程修改同一变量，我们只能通过调整代码结构去规避一些线程不安全的代码，但这方案不够通用；Java 中有个东西，String 就是采取了“不可变”特性，确保线程安全；</p>
<blockquote>
<p>String 实现“不可变”的效果是将内部的 char 数组设为 private（与 final 无关），导致无法访问，因此无法修改；</p>
</blockquote>
<p><strong>问题 3：</strong></p>
<blockquote>
<p>此处所说的原子性，就是指是数据库里事务提到的 ACID 中的原子性；</p>
<p>如果修改操作只是对应到一个 CPU 指令就可以认为是原子的，CPU 不会出现“一条指令执行一半”，但是如果对应到多个 CPU 指令就不是原子的，比如：++、--、+=、-=......；</p>
</blockquote>
<p>原子性：</p>
<p><img src="./thread14.png" alt="" /></p>
<p>上面图片中被两个客户端并发拆开了，但【检查+扣减】应该作为一个整体，不可拆开；</p>
<p>所以在 Java 中解决线程安全问题最主要的方案就是<strong>加锁</strong>。</p>
<p>通过加锁操作，让不是原子的操作，打包成一个原子的操作；</p>
<blockquote>
<p>计算机中的锁与生活中的锁，是同样的概念。互斥/拍他</p>
</blockquote>
<p>这也就是下一节我们要讲的~</p>
<p>对了，对于上面的线程安全问题，我们这样解决：</p>
<pre><code>public class Demo7 {
    public static int count;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -&gt; {
            synchronized (locker) {
                for (int i = 0; i &lt; 50000; i++) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -&gt; {
            synchronized (locker) {
                for (int i = 0; i &lt; 50000; i++) {
                    count++;
                }
            }
        });

            t1.start();
            t2.start();
            t1.join();
            t2.join();
        System.out.println(count);
    }
}
</code></pre>
<h2>5.<code>synchronized</code> 关键字</h2>
<p>Java 中的 <code>synchronized</code>本质就是加锁；但要注意，有时候也不是写了 <code>synchronized</code> 就 100%线程安全，这个需要具体代码具体分析~</p>
<p>拿一段代码举个例子：</p>
<pre><code>public class Demo6 {
	public static void main(String[] args) {
		Object o1 = new Object();
		Object o2 = new Object();
		Thread t1 = new Thread(() -&gt; {
			synchronized(o1) {
				try {
					Thread.sleep(100000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
			synchronized(o2) {
				System.out.println("t1线程两个锁都获取到");
			}
		});
		Thread t2 = new Thread(() -&gt; {
			synchronized(o1) {
				try {
					Thread.sleep(100000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
			synchronized(o2) {
				System.out.println("t2线程两个锁都获取到");
			}
		});

		t1.start();
		t2.start();
	}
}
</code></pre>
<p>当这段代码开始运行后，可以调用 <code>jconsole</code> 去查看一下线程状态；</p>
<p><img src="./thread15.png" alt="" /></p>
<p>此时可以看到 t2 线程处于 <code>BLOCKED</code> 状态，也就是我们之前所说的阻塞状态；这里阻塞的原因就是 t1 线程上锁 o1 之后一直 <code>sleep</code>。此时线程 t2 想同步运行，但是锁在 t1 手上，所以 t2 第一段代码就执行不了，必须等到 t1 线程释放锁之后 t2 才能继续运行。这就是前面提到的 <code>BLOCKED</code> 阻塞状态；</p>
<h3>5.1 <code>synchronized</code> 的特性</h3>
<h4>5.1.1 互斥性</h4>
<p>我们首先需要知道锁是什么，在生活中好很理解：</p>
<p><img src="./thread16.png" alt="" /></p>
<p>对，这就是锁，因为厕所只能容纳一人，当他进去时就得上锁，出来时就得解锁；在代码中就这样理解，一个线程先上了锁，其他线程只能等待这个线程释放，这也就是<strong>互斥</strong>的含义；</p>
<p><code>synchronized</code>中文名叫：同步锁；在 JVM 层面对应的是：<code>Monitor Lock</code> 监视器锁</p>
<p>也就是说每一个 Java 对象都可以关联一个监视器；</p>
<p>它在代码中的流程：</p>
<pre><code>Thread t1 = new Thread(() -&gt; {
    synchronized (locker) {
        for (int i = 0; i &lt; 50000; i++) {
            count++;
        }
    }
});
</code></pre>
<p>尝试获取对象对应的锁 -&gt; 获取成功 -&gt; 执行代码 -&gt; 释放锁</p>
<p>如果锁已经被其他线程占用，那么线程就会等待，直到其他线程释放锁，这中间还会存在锁竞争；</p>
<p>至于为什么需要互斥，就拿之前的火车票问题来说，只剩一张票了，两个客户端申请访问时必须只能一个一个访问，而不能并发访问；</p>
<p>以上就是互斥的必要性；</p>
<h4>5.1.2 可重入性</h4>
<p><code>synchronized</code> 第二个特点就是：可重入性</p>
<p><strong>引入可重入的概念目的其实就是为了解决死锁问题，那么什么是死锁问题？</strong></p>
<blockquote>
<p>假如下方的代码是不可重入的，那么当已经进入锁住状态的方法一调用方法二时，由于锁已被用且还未释放，因此方法二无法加锁且无法进入。最后就导致阻塞等待，两个方法都无法继续往下执行；</p>
</blockquote>
<p>可重入性概念刚理解可能不太好明白，意思是：</p>
<blockquote>
<p>一个线程已经拥有某把锁之后，可以再次进入有同一把锁保护的代码；</p>
</blockquote>
<p>比如：</p>
<pre><code>public class Demo {
    synchronized public void method1(){
        method2();
    }

    synchronized public void method2(){
        System.out.println("执行method2");
    }
}
</code></pre>
<p>当前线程调用<code>method1()</code>方法，线程 A 进入<code>method1()</code> -&gt; 获得对象锁 -&gt; 调用<code>method2()</code> -&gt; <code>method2()</code>也是 <code>synchronized</code>；</p>
<p>又或者像是这样：</p>
<pre><code>synchronized (counter) {		// 1.此处开始锁住
    synchronized (counter) {		// 2.直接放行
        synchronized (counter) {		// 3.直接放行
            counter.add();
        }	// 3-&gt;2 退出第三层
    }	// 2-&gt;1 退出第二层
}	// 1-&gt;0 退出第一层
</code></pre>
<p>至于 JVM 如何知道哪个“}”是真正要解锁的那个呢？</p>
<blockquote>
<p>JVM 执行的是 <code>.class</code> 字节码，不是 Java 源代码里的 <code>{}</code>。<br />
<code>synchronized</code> 代码块之所以能正确加锁、解锁，是因为编译器会把它编译成专门的字节码指令，而不是 JVM 运行时去判断“这个 <code>}</code> 属于谁”。</p>
</blockquote>
<p>如果<code>synchronized</code>不可重入，这里就会发生线程 A 访问<code>method2()</code>时一直等待释放锁，因此会永远等待，也就是发生<strong>死锁</strong>；但由于可重入性的原因，因此此处不会发生阻塞；</p>
<h4>5.1.3 死锁问题（重）</h4>
<p>关于死锁问题，已经在上面初步解释过了，就再具体说一遍：</p>
<p>死锁就是</p>
<blockquote>
<p>家钥匙锁车里了，车钥匙锁家里了</p>
</blockquote>
<p>很好理解吧~</p>
<p><strong>构成死锁主要有这四个必要条件（重点）</strong></p>
<blockquote>
<p>1.锁是互斥的。一个线程拿到锁之后，另一个线程再尝试获取锁，必须要阻塞等待；（锁的基本性质）</p>
<p>2.锁是不可抢占的。线程 1 拿到锁，线程 2 也尝试获取这个锁，线程 2 必须阻塞等待，而不是线程 2 直接把锁抢过来；（锁的基本特性）</p>
<p>3.请求和保持。一个线程拿到锁 1 之后，不释放锁 1 的前提下，获取锁 2</p>
<p>4.循环等待。多个线程、多把锁之间的等待过程构成“循环”</p>
</blockquote>
<p>以上 1、2 点至少 Java 的 synchronized 是遵守这两点，</p>
<p>破坏掉上述的 3 或 4 任何一个条件都能够打破死锁；</p>
<p>（打破 3：把嵌套的锁改为并列的锁）</p>
<p>（打破 4：加锁的顺序做出约定）</p>
<p>在代码中构成死锁问题通常有下面三种情况：</p>
<p><strong>情况一：两个线程、两把锁交叉等待</strong></p>
<pre><code>Thread t1 = new Thread(() -&gt; {
    synchronized (lockA) {
        synchronized (lockB) {
        }
    }
});

Thread t2 = new Thread(() -&gt; {
    synchronized (lockB) {
        synchronized (lockA) {
        }
    }
});
</code></pre>
<p>这是 Java 中典型的死锁，<strong>如果</strong>执行成这样</p>
<blockquote>
<p>t1：拿到 A</p>
<p>t2：拿到 B</p>
<p>t1：等待 B</p>
<p>t2：等待 A</p>
</blockquote>
<p>这就死锁了，这也可以叫所顺序不一致导致的死锁；<strong>注意我说的是如果</strong>，因为线程 t1 与 t2 是并发的，有可能 t1 和 t2 一样快，有可能 t1 比 t2 快...这样的结果是不确定的。</p>
<blockquote>
<p>针对情况一就是同一加锁顺序，也就是说如果：必须同时获取多把锁，所有代码必须按照固定顺序获取。</p>
</blockquote>
<p><strong>情况二：哲学家就餐问题（多线程、多资源形成环形等待）</strong></p>
<p>假设有 5 个哲学家，每个人面前都有一根筷子，总共 5 根，每个哲学家必须<code>左筷子</code>+<code>右筷子</code>才能吃饭。
<img src="./thread17.png" alt="" /></p>
<p>换成代码就是这样理解：5 个人就相当于 5 个线程、5 根筷子就相当于 5 把锁，每个线程只需要拿到其中两根筷子即可~</p>
<blockquote>
<p>大部分情况下上述模型可以很好的运转，只是在一些极端情况下会造成死锁，譬如：</p>
<p>同一时刻，大家都想吃面条，同时拿起左手的筷子</p>
</blockquote>
<p>虽然上述情况的概率很小，但是只要基数足够大，任何小概率事件都是大事件；</p>
<blockquote>
<p>针对情况二给出两种解决方法：</p>
<p>方法一：规定筷子获取顺序，让所有哲学家统一遵守“先拿编号小的，再拿编号大的”。</p>
<p>方法二：最多允许 4 个哲学家同时拿筷子，此时至少有一个哲学家有机会凑齐两根筷子，也就是限制同时参与资源竞争的线程数量。</p>
</blockquote>
<h3>5.2 <code>synchronized</code>使用示例</h3>
<p><code>synchronized</code> 本质上要修改指定对象的“对象头”。从使用角度看，<code>synchronized</code> 也是必要搭配一个具体的对象来使用；</p>
<h4>5.2.1 <code>synchronized</code> 修饰代码块</h4>
<p>这里要明确指定锁哪个对象。</p>
<p>格式：</p>
<pre><code>synchronized(锁对象){
    // 需要保证线程安全的代码
}
</code></pre>
<pre><code>synchronized(lock){
    count++;
}
</code></pre>
<p>前面已经解释过，线程进入代码块 -&gt; 尝试获取 lock 对象的锁 -&gt; 获取成功 -&gt; 执行 count++ -&gt; 释放 lock 锁</p>
<p>如果此时另外一个线程也执行<code>synchronized(lock)</code>，那么因为他们使用的是同一个 lock 所以只能排队。</p>
<p>至于为什么一定要是同一个对象，是因为两个线程，针对同一个对象加锁，才会产生互斥效果；如果是不同的锁对象，此时不会有互斥效果，线程安全问题就没有得到改变...</p>
<p>这里提到的锁对象，关于这个对象的类型是什么并不重要（在 Java 中任何一个对象都可以用作'锁'），重要的是是否有多个线程尝试针对这同一个对象加锁（是否在竞争同一个锁）</p>
<p>再拿前面的多线程 50000 次循环并发举例：</p>
<pre><code>Thread t1 = new Thread(() -&gt; {
    for (int i = 0; i &lt; 50000; i++) {
        synchronized (locker) {    
            count++;
        }
    }
});
</code></pre>
<pre><code>Thread t1 = new Thread(() -&gt; {
    synchronized (locker) {
        for (int i = 0; i &lt; 50000; i++) {
            count++;
        }
    }
});
</code></pre>
<p>以上两段代码看似运行结果相同，但实际运行过程差别很大，特别是在效率上；</p>
<p>这是代码一的运行过程：</p>
<p><img src="./thread18.png" alt="" /></p>
<p>这是代码二的运行过程：</p>
<p><img src="./thread19.png" alt="" /></p>
<p>至于为什么 Java 中不使用 <code>lock</code> + <code>unlock</code> 函数的方式的话，这个就有点意思了：</p>
<p>当然其实 Java 中也是有 <code>lock/unlock</code> 风格的锁，但一般很少用，因为...</p>
<pre><code>Lock locker  = new Locker();
public void func() {
locker.lock();
// something code
locker.unlock();
</code></pre>
<p>这样的写法其实会容易忘记把 unlock 补上，就会导致其他线程死锁；</p>
<blockquote>
<p>你要是说“只要我写了 lock，就会立即加上 unlock~”，那你纯纯就是大猪蹄子行为；</p>
<p>就像是你给女朋友保证，这辈子我只爱你一个，永远不会变心~</p>
</blockquote>
<p>再来个例子：</p>
<pre><code>void func() {
    lock();

    if (cond) {
        unlock();
        return;
    }

    if (cond2) {
        unlock();
        return;
    }
    func2();
    
    unlock();
}
</code></pre>
<p>其中 func2()又是调用：</p>
<pre><code>void func2() {
    func3();
}

void func3() {
    func4();
}

void func4() {
    throw new Exception();
}
</code></pre>
<p>此时，代码中抛出异常，那么也有可能使 unlock 执行不到~</p>
<p>所以 Java 中采取 synchronized 就能保证，只要出了“}”一定能释放锁，无论因为 return 还是因为异常里，也无论里面调用了哪些代码，都是可以确保 unlock 操作执行到的；</p>
<h4>5.2.2 <code>synchronized</code> 修饰普通方法</h4>
<p>synchronized 还可以直接修饰方法：</p>
<pre><code>public class Counter {
    private int count;
    public synchronized void increment(){
        count++;
    }
}
</code></pre>
<p>其实像是 StringBuffer、Vector 这些对象，方法上就是带有 synchronized：</p>
<p><img src="./thread20.png" alt="" /></p>
<p>所以会说这些对象会线程安全；</p>
<p>不过如果 synchronized 修饰的是静态方法的话，就<strong>相当于针对类对象加锁</strong>（从反射可以了解）；</p>
<ul>
<li><code>synchronized</code> 修饰<strong>普通实例方法</strong>：锁的是 <code>this</code></li>
<li><code>synchronized</code> 修饰<strong>静态方法</strong>：锁的是当前类对应的 <code>Class</code> 对象，也就是 <code>类名.class</code></li>
</ul>
<p>例如：</p>
<pre><code>public class Counter {
    private int count;
    public synchronized void increment(){
        count++;
    }
}
</code></pre>
<p>此处：</p>
<pre><code>public synchronized void increment()
</code></pre>
<p>等价于：</p>
<pre><code>public void increment(){
    synchronized(this){
        count++;
    }
}
</code></pre>
<p>也就是说：</p>
<blockquote>
<p>普通 <code>synchronized</code> 方法锁住的是当前对象。</p>
</blockquote>
<p>在实际中通常会是这样：</p>
<pre><code>Counter counter = new Counter();
</code></pre>
<p>然后会这样去调用<code>counter.increment()</code>，实际上锁的是 counter 对象，也就是 this==counter</p>
<p>然后多个线程：</p>
<pre><code>Thread t1 = new Thread(() -&gt; {
    counter.increment();
});

Thread t2 = new Thread(() -&gt; {
    counter.increment();
});
</code></pre>
<p>此时线程 A：</p>
<blockquote>
<p>获取 counter 对象锁</p>
<p>执行 increment</p>
</blockquote>
<p>线程 B：</p>
<blockquote>
<p>等待 counter 对象锁</p>
</blockquote>
<p>而如果是：</p>
<pre><code>Counter counter1 = new Counter();
Counter counter2 = new Counter();
</code></pre>
<p>这样的话两个线程分别尝试获取两把不同的锁，就不会产生竞争；</p>
<table>
<thead>
<tr>
<th><strong>写法</strong></th>
<th><strong>锁对象</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>synchronized(lock){}</code></td>
<td>lock对象</td>
</tr>
<tr>
<td><code>public synchronized void method(){}</code></td>
<td>当前对象 this</td>
</tr>
<tr>
<td><code>public synchronized static void method(){}</code></td>
<td>当前类 Class 对象</td>
</tr>
</tbody>
</table>
<p>当然啦你也可以这样去理解：</p>
<p>代码块：</p>
<p><code>synchronized(lock)</code>就像指定某个房间上锁；锁是指 <code>lock</code> 房间</p>
<p>普通方法：</p>
<p><code>synchronized method</code>就像是给当前员工办公室上锁；锁是指当前员工对象</p>
<p>静态方法：</p>
<p><code>static synchronized method</code>就是给整个公司总部上锁；这里的锁就是指整个 Class</p>
<p>锁虽好，但不可贪~</p>
<pre><code>synchronized(lock){
    // 很多代码
}
</code></pre>
<p>可能会多人会犯这样一个错误，虽然线程安全，但是效率可能就不太好。因为真正需要保护的可能就只有<code>count++</code>，就像我前面举过的例子：<code>synchronized (locker)</code>放在 for 循环内与 for 循环外的对比。当你锁住的代码越多，线程等待的时间就越长，并发能力就越低。所以在实际开发中：</p>
<blockquote>
<p><code>synchronized</code> 的范围应该尽量控制，只保护真正需要同步的数据~</p>
</blockquote>
<h2>6.<code>volatile</code> 关键字</h2>
<p>在前面讲线程安全问题的时候，提到过几个容易引发问题的原因：</p>
<p>原子性、内存可见性以及指令重排序</p>
<p>其中原子性的问题就在上面的<code>synchronized</code>已经大概了解如何处理了，但还有一个一直被搁置的问题：</p>
<blockquote>
<p>一个线程修改了变量之后，为什么另一个线程有时候看不到？</p>
</blockquote>
<p>当然前面其实遇到过一次，在中断线程的时候写过：<code>public volatile boolean isQuit = false;</code></p>
<p>当时只是说“加上 <code>volatile</code> 后，一个线程修改 <code>isQuit</code>，另外一个线程能够及时看到。”</p>
<p>那现在就来正式解释一下~</p>
<h3>6.1 内存可见性</h3>
<p>先来举个例子：</p>
<blockquote>
<p>假设老师布置作业后，让课代表把作业内容卸载教室后面的黑板上：“今日作业：完成练习册第 10 页”</p>
<p>这个黑板相当于全班同学都能看到的公共信息区域。</p>
<p>但是张三觉得每次回头看黑板太麻烦，于是第一次看到作业后，就把内容记在自己的草稿纸上：“今日作业：完成练习册第 10 页”</p>
<p>李四也记了一份自己的。后来老师临时修改了作业，于是让课代表把后黑板改成：“今日作业：完成练习册第 10～12 页”</p>
<p>此时，黑板上的内容已经更新了。</p>
</blockquote>
<p>但问题来了：</p>
<blockquote>
<p>如果张三之后一直只看自己之前记下来的草稿纸，而没有重新去看后黑板，他看到的仍然只会是“今日作业：完成练习册第 10 页”</p>
</blockquote>
<p>张三并不知道公共信息已经发生了变化。这就很像多线程中的内存可见性问题：</p>
<p><strong>一个线程已经修改了共享变量，但是另一个线程没有及时看到这个变量的最新值。</strong></p>
<p>对于上述这样的情况，你可以抽象理解为“主内存”和“线程工作内存”模型</p>
<p>代码示例：</p>
<pre><code>public static int flag = 0;
public static void main(String[] args) {

    Thread t1 = new Thread(() -&gt; {
        while (flag == 0) {
            
        }
        System.out.println("t1 线程结束");
    });

    Thread t2 = new Thread(() -&gt; {
        // 针对 flag 进行修改
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入 flag 的值：");
        flag = scanner.nextInt();
    });
    t1.start();
    t2.start();
}
</code></pre>
<p>这段代码当运行起来后，输入任何数字后<strong>按单线程的思想</strong>应该不进入循环而停下来，但事实并非</p>
<p><img src="./thread21.png" alt="" /></p>
<p><img src="./thread22.png" alt="" /></p>
<p>显然这是一个 BUG（线程安全问题）</p>
<p>一个进程读取，一个进程修改，修改线程的值，并没有被读线程读到。这也就是要说的“内存可见性问题”</p>
<p>其实上面的问题是，每个线程在读取主内存的时候都会先拷贝一份放到自己的工作内存中，如果要修改就会先在自己的工作内存中完成修改，随后就将修改好的值放入主内存中。但是由于 t1 只是进行读操作，它只会去读相对较近的内存（也就是 t1 的工作内存）</p>
<p><img src="./thread23.png" alt="" /></p>
<p>线程在运行过程中，并不一定每一次使用变量都重新读取共享区域中的最新结果，而可能使用自己工作内存中的值。我所说的“工作内存”可以先抽象理解为 CPU 寄存器、缓存有关的区域，因为访问这些位置速度更快，但也可能产生数据不一致。</p>
<p>产生这样的原因：</p>
<blockquote>
<p>研究 JDK 的大佬们就希望通过让编译器 &amp; JVM 对程序员写的代码，自动的进行优化</p>
<p>本来写的代码是进行 xxxxx，编译器 / JVM 会在你原有逻辑不变的前提下，对你的代码进行调整，是程序效率更高</p>
</blockquote>
<p>JVM 和 CPU 为了提高执行效率，可能会对变量读取、缓存以及指令执行进行优化。在单线程环境中，这些优化不能改变程序原有语义；但在多线程环境下，如果程序没有通过 <code>volatile</code>、<code>synchronized</code> 等机制建立正确的线程间同步关系，就可能出现一个线程无法及时观察到另一个线程修改结果的情况~</p>
<p>上面的过程细化理解就是：</p>
<pre><code>while (flag == 0) {

}
</code></pre>
<blockquote>
<p>load 为读内存操作，cmp 是 CPU 寄存器操作；load 的时间开销可能是 cmp 的几千倍</p>
</blockquote>
<p>其中的<code>flag == 0</code>为 cmp 运算操作，而比较操作又细化为 <code>load</code>、<code>cmp</code>（加载、运算）短时间内这个循环会进行很多次，执行过程中 JVM 感知到 <code>load</code> 反复执行的结果好像都是一样的。JVM 就会想：执行这么多次读 flag 操作发现值都为 0，既然结果一样，为什么还要反复执行这么多次呢~</p>
<p>于是就把读内存的操作优化成读寄存器（把内存的值读到寄存器了，后续再 load 就不再重新读内存）</p>
<p>此后，等到用户开始输入新的值真正修改 <code>flag</code> 时，t1 线程就感知不到了；当然如果引入 <code>sleep</code> 去等用户输入的话，那样效率又太低了；</p>
<p>于是就引入了 <code>volatile</code> 关键字，通过这个关键字来修饰某个变量，此时编译器这对这个变量的读取操作就不会被优化成寄存器了</p>
<pre><code>public volatile static int flag = 0;
</code></pre>
<p><img src="./thread24.png" alt="" /></p>
<p><code>volatile</code>解决了内存可见性问题·，但不是解决原子性问题；</p>
<h3>6.2 JMM Java 内存模型</h3>
<p>前面我们提到的“主内存”和“线程工作内存”模型其实就是 JMM，其含义你可以理解为：</p>
<blockquote>
<p>Java 用来规定“多个线程之间如和读写共享变量，以及这些读写什么时候对线程可见”的一套规则。</p>
</blockquote>
<p>在 Java 官方文档的描述中：</p>
<blockquote>
<p>每个线程，有一个自己的“工作内存”，同时这些线程共享同一个“主内存”，当一个线程循环进行上述读取变量操作的时候，就会把主内存中的数据拷贝到该线程的工作内存中。后续另一个线程修改，也是先修改自己的工作内存，拷贝到主内存里。由于第一个线程仍然在读自己的工作内存，因此刚知不道主内存的变化。</p>
</blockquote>
<p>这与我们前面讲到的把读内存的操作，优化成读寄存器操作是一样的意思。</p>
<p>以及上面所说的“工作内存”（work memory）就是指与 CPU 寄存器、缓存等相关的区域；</p>
<p>然后明确<code>synchronized</code> 能够保证原子性，而 <code>volatile</code> 保证的是内存可见性。</p>
<p>好了，以上就是关于 <code>volatile</code> 的内容...</p>
<h2>7.<code>wait</code> 和 <code>notify</code></h2>
<p>前面学习多线程的时候，一直在强调一件事：</p>
<blockquote>
<p>多个线程之间的执行顺序通常是不好控制的</p>
</blockquote>
<p>谁先拿到 CPU、谁先执行，本身就带有一定随机性，但实际开发里肯定是不能“各干各的”。线程里一定也是存在“ 你先做完，我再继续”的协作关系。</p>
<p>所以 Java 就因此提供了：<code>wait()</code>、<code>notify()</code>、<code>notifyAll()</code>来实现线程之间的等待和唤醒；注意<code>wait()</code>、<code>notify()</code>、<code>notifyAll()</code>都是 Object 类的方法，所以 Java 中的任意对象都提供了 <code>wait()</code>和 <code>notify()</code>。</p>
<p><code>wait()/wait(long timeout)</code>：让当前线程进入等待状态；</p>
<p><code>notify()/notifyAll()</code>：唤醒在当前对象上等待的线程；</p>
<h3>7.1 <code>wait()</code> 方法</h3>
<p>从上面我们知道了 <code>wait()</code>方法的作用是：让当线程先别干了，进入等待；</p>
<p>比如：</p>
<pre><code>Object lock = new Object();

Thread t1 = new Thread(() -&gt; {
	synchronized (lock) {
		System.out.println("开始等待");
		lock.wait();
		System.out.println("等待结束");
	}
}
</code></pre>
<p>当执行到<code>lock.wait()</code>时候线程就会停下来，至于后面要打印的内容就不会立即执行了；</p>
<p>此时当前线程主要会经历这样三个过程：</p>
<p><strong>当前线程进入等待状态 -&gt; 释放当前持有的 lock 锁 -&gt; 以后被唤醒时，再重新尝试获取 lock 锁</strong></p>
<p>其中的<strong>释放锁</strong>这一步很关键；</p>
<p>那至于<strong>为什么要释放锁</strong>呢，继续举个例子：</p>
<p>（财务室在<code>synchronized (lock) {}</code>中）</p>
<blockquote>
<p>假设李四进入财务室，此时李四手里拿着钥匙，然后李四发现：“我现在还缺一份材料，得等王五送过来。”</p>
</blockquote>
<blockquote>
<p>于是此时就调用<code> lock.wait()</code>，然后王五想进财务室送材料：“钥匙还在李四手里，我进不去”</p>
<p>那么李四：“材料不来我就不继续 ”，接着王五：“你不放钥匙我就送不进去”</p>
<p>这个时候两个人就直接卡住了，所以所以 <code>wait()</code>必须要释放锁让王五进来。</p>
</blockquote>
<p>当然啦，如果你<code>wait()</code>不写在 synchronized 中就会抛异常~</p>
<p>而<code>wait()</code>要<strong>等到什么时候</strong>呢？</p>
<p><code>wait()</code>默认是一直等，等到被唤醒，所以它对应的状态通常就是 <code>WAITTING</code> 状态，但<code>wait()</code>是提供带参数的，时间参数单位是毫秒。然后关于<code>wait(0)</code>，类似于<code>sleep(0)</code>，不是等待 0 毫秒，而是无限期等待，直到被 <code>notify()</code>、<code>notifyAll()</code> 唤醒，或者线程被中断。</p>
<p>ok 我们上面也说了“<code>notify()</code>、<code>notifyAll()</code> 唤醒，或者线程被中断”通常就是这三种方式能把<code>wait()</code>唤醒。其中关于线程被中断这点，是指另外一个线程直接 <code>thread.interrupt()</code>，那么等待的线程会通过<code>InterruptedException</code>收到通知，与前面的 <code>sleep()</code>、<code>join()</code>是同一类思路；</p>
<p>但是<code>wait()</code>会释放所，而 <code>sleep()</code>不会释放锁~</p>
<p>注意被 notify 唤醒，不代表线程马上执行。因为线程醒来以后，会先从 ：</p>
<p><strong><code>WAITING</code> 状态出来 -&gt; 重新去竞争那把 lock -&gt; 拿到所以后 -&gt; 才能继续执行 <code>wait()</code> 后面的代码</strong></p>
<p>被唤醒以后，线程并不能马上继续执行，而是需要重新参与这把锁的竞争。什么时候真正拿到锁，仍然由线程调度决定。</p>
<p><img src="./thread25.png" alt="" /></p>
<p>记得要求 <code>synchronized</code>的锁对象必须和<code>wait</code>的对象是同一个；</p>
<h3>7.2 <code>notify()</code>方法</h3>
<p>然后接着刚才说的，李四进入到财务室然后睡着了，王五已经把材料放在桌子上，这个时候问题随之而来：</p>
<blockquote>
<p>李四都已经睡着了，谁来告诉他“别睡了，可以继续干活了”？</p>
</blockquote>
<p>这个时候就需要 <code>notify()</code>，而<code>notify()</code>的作用确实就是<strong>唤醒一个正在当前对象上</strong><code>**wait()**</code><strong>的线程</strong>；</p>
<p>当然了，既然<code>wait()</code>要配上<code>synchronized</code>，那<code>notify()</code>也是必须得配上<code>synchronized</code>使用了。因为他们都是围绕着同一个对象对应的<code>monitor</code>锁进行协调。所以这边必须对应一把锁。</p>
<p>对了，务必还要确保先<code>wait()</code>，后 <code>notify()</code>。相反如果先<code>notify()</code>那么<code>wait()</code>也就无法唤醒。</p>
<p>比如：</p>
<pre><code>public class Demo {
    public static void main(String[] args)
            throws InterruptedException {

        Object lock = new Object();
        Thread t1 = new Thread(() -&gt; {=
            synchronized (lock) {
                System.out.println("李四：材料没来，我先等等");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("李四：材料到了，继续工作");
            }
        }, "李四");

        Thread t2 = new Thread(() -&gt; {
            synchronized (lock) {
                System.out.println("王五：材料送到了");
                lock.notify();
                System.out.println("王五：我已经通知李四了");
            }
        }, "王五");

        t1.start();
        Thread.sleep(1000);
        t2.start();
    }
}
</code></pre>
<p>以上代码的执行过程就如下方：</p>
<p><img src="./thread26.png" alt="" /></p>
<p>注意上面所描述到的“李四重新竞争 lock”，李四的线程并不是接着马上就向后运行，而是重新与其他线程相互竞争；当然，也不是 <code>notify</code> 唤醒后 <code>wait</code> 马上就进入工作的...</p>
<p><strong>那有个问题，假如有多个线程都在 wait 呢？</strong></p>
<p><code>notify</code> 还是规定只能唤醒一个，但是具体叫醒谁，并没有固定保证。但是如果想唤醒全部 wait 就可以用到 <code>notifyAll</code>；可以这样理解：<code>notify</code> 就是“老师面对三个趴桌子睡觉的人喊：起来一个帮我搬书！”，那么起来的是谁不一定；而 <code>notifyAll</code> 就是老师对这三个人都点名，让他们都起来去搬书。</p>
<p>然后在这里就可以区分 <code>WAITING</code> 和 <code>BLOCKED</code>了，假设李四调用了 <code>lock.wait()</code>，那么李四就是 <code>WAITING</code>（因为他主动释放锁，并通知被人等待），此时王五拿着 <code>lock</code>，赵六也想拿到锁但进不去，那赵六就是 <code>BLOCKED</code>。</p>
<h3>7.3 <code>notifyAll()</code> 方法</h3>
<p>（本来不想写 7.3，但是想了想还是需要补充一下哈）</p>
<p>前面举的这个例子，“老师面对三个趴桌子睡觉的人”如果想都叫醒就是用 notifyAll，但是调用了<code>lock.notifyAll()</code>也并不是三个线程马上一起执行后续代码，只是三个线程被唤醒，但还是<strong>需要重新竞争原来的那把锁</strong>。</p>
<h3>7.4 <code>wait()</code> 和 <code>sleep()</code>的区别</h3>
<p>到这里，我们已经很清晰的知道了 wait 和 sleep 完全没有可比性，毕竟一个是用于线程之间的通信，一个是让线程阻塞一段时间；唯一相同的就是都可以让线程放弃执行一段时间.</p>
<p>但还是需要总结下：</p>
<ol>
<li>wait 需要搭配 synchronized 使用，而 sleep 不需要；</li>
<li>wait 是 Object 的方法，而 sleep 是 Thread 的静态方法；</li>
</ol>
<p>我让 GPT 综合给出了一个表格，内容如下：</p>
<table>
<thead>
<tr>
<th>对比</th>
<th><code>wait()</code></th>
<th><code>sleep()</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>所属类</td>
<td><code>Object</code></td>
<td><code>Thread</code></td>
</tr>
<tr>
<td>主要作用</td>
<td>线程之间等待、协作</td>
<td>让当前线程休眠</td>
</tr>
<tr>
<td>是否释放锁</td>
<td><strong>释放锁</strong></td>
<td><strong>不释放锁</strong></td>
</tr>
<tr>
<td>是否必须在 <code>synchronized</code> 中</td>
<td>是</td>
<td>否</td>
</tr>
<tr>
<td>无参时典型状态</td>
<td><code>WAITING</code></td>
<td><code>sleep</code> 必须指定时间</td>
</tr>
<tr>
<td>指定时间时</td>
<td><code>TIMED_WAITING</code></td>
<td><code>TIMED_WAITING</code></td>
</tr>
<tr>
<td>如何恢复</td>
<td><code>notify/notifyAll</code>、超时、中断等</td>
<td>时间到或被中断</td>
</tr>
</tbody>
</table>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>进程基础：资源管理、调度与上下文切换</title>
    <link href="https://blog.syrena.cn/posts/javaee-process/javaee-process/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javaee-process/javaee-process/</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>理解程序与进程的区别，认识PCB、进程状态、调度以及进程的创建与终止机制.</summary>
    <content type="html"><![CDATA[<p><strong>你有没有想过，当我们双击一个程序时，操作系统到底做了什么？</strong></p>
<p>以 Windows 环境为例，磁盘中的<code>.exe</code>（可执行程序）文件本质上是一个静态程序，里面包含着程序运行所需的机器指令、数据，本身不会执行。只有当操作系统把程序加载到内存，为它分配运行需要的资源，并且让 CPU 执行其中的指令后，这个静态的可执行文件才真正变成一个正在运行的程序。</p>
<p>这个运行的程序实例，就是我想说的<strong>进程</strong>（Process）</p>
<h2>1. 程序与进程</h2>
<h3>1.1 程序</h3>
<p>点击键盘 CTRL + SHIFT + ESC 就会弹出下面这样一个界面，也就是任务管理器，正常会运行上百个进程。了解到这里，就应该想问出<strong>进程到底是什么</strong>？为什么<strong>操作系统需要进程</strong>？一个磁盘中的程序，又是<strong>如何变成一个正在运行的进程</strong>的？</p>
<p>首先得知道：<strong>程序不等于进程</strong></p>
<p><img src="./process0.png" alt="" /></p>
<p>程序 = 存放在磁盘上的一组指令和数据</p>
<p>就比如一个这样存放在你的电脑硬盘里的文件：</p>
<pre><code>C:\Program Files\Google\Chrome\Application\chrome.exe
</code></pre>
<p>只要<code>chrome.exe</code>还没有启动，他就只是磁盘上的一个程序。</p>
<p>主要因为一下几点：</p>
<ol>
<li>不占 CPU 执行时间；</li>
<li>没有正在使用的寄存器；</li>
<li>没有自己的运行状态；</li>
<li>没有变化的内存数据；</li>
</ol>
<p>综上，所以说程序是<strong>静态</strong>的。</p>
<h3>1.2 进程</h3>
<p>当我们双击<code>chrome.exe</code>后，事情就不一样了，Windows 操作系统就会开始为它准备运行环境。</p>
<p>补充一下<strong>操作系统</strong>（Operating System, OS）扮演的角色：</p>
<blockquote>
<p>操作系统就是计算机里的“总管 + 调度中心 + 翻译层”，可以理解为酒店里的经理吧或者说公司里的运营官，嗯对；操作系统是介于硬件和软件之间，负责统一管理真正干活的 CPU、内存、硬盘、显卡、网卡...这些硬件，对没错就是牛马们。</p>
</blockquote>
<p>这时候呢，操作系统就有以下动作：</p>
<p><img src="./process1.png" alt="" /></p>
<p>此时，它就不再只是一个磁盘中的文件里，而是程序的一次<strong>运行实例</strong>；</p>
<p>根据上面的描述，我总结出一句：</p>
<blockquote>
<p>程序描述“要做什么”，进程描述“程序现在运行到什么状态”</p>
</blockquote>
<h2>2. 一个进程中有什么</h2>
<p>不知道会不会有个疑惑觉得<strong>为什么会需要“进程”这个东西？</strong></p>
<p>就比如我当前电脑正在同时运行这些软件：</p>
<p><img src="./process2.png" alt="" /></p>
<p>CPU、内存、磁盘这些资源都很有限，操作系统就得知道哪些程序正在运行、占用了多少内存、谁正在等待 I/O、谁已经准备好运行，以及接下来应该让谁获得 CPU。</p>
<p>所以对操作系统来说，它就会把每一个进程<strong>抽象为用一个或多个结构体</strong>来描述和管理。</p>
<p>知道上面结果后就会容易想，一个<strong>进程中都有什么？或者一个进程运行本身需要哪些东西？</strong></p>
<p>我给出一个简单的模型：</p>
<p><img src="./process3.png" alt="" /></p>
<p>虚拟地址空间本身是一个比较大的话题，涉及代码段、数据段、堆、栈、内存映射...一些内容。这里暂时把它理解为：<strong>操作系统为每个进程提供的一套独立的虚拟内存视图</strong>，具体的内存管理机制后面有机会再展开~😁</p>
<p>上图其中<strong>运行所需的系统资源</strong>也包含里很多，比如 Chrome 运行起来后，就会需要内存、网络连接 Socket、文件描述符、权限信息、若干线程。 到这里我们能知道<strong>一个进程运行起来，需要代码、数据、内存以及各种操作系统资源。</strong></p>
<p>但是，接着我再抛出一个问题：</p>
<blockquote>
<p>“<strong>操作系统怎么知道这些东西属于哪一个进程？</strong>”</p>
</blockquote>
<h2>3. PCB：操作系统如何管理进程</h2>
<p>上面已经说过“操作系统会把每一个进程<strong>抽象为用一个或多个结构体</strong>来描述和管理”。但其实，在操作系统原理中，通常把描述和管理一个进程所需要的核心信息统称为 <strong>PCB</strong>（Process Control Block，进程控制块），它会再把多个进程组织起来。</p>
<p>你可以脑补为这样：</p>
<pre><code>struct PCB {
    int pid;              // 进程编号
    int state;            // 运行、就绪、阻塞……
    registers regs;       // CPU寄存器现场
    memory_info memory;   // 内存信息
    file_info files;      // 打开的文件
    priority prio;        // 优先级
    ...
};
</code></pre>
<p>操作系统里就会有很多这样的“进程档案”，但真正的进程包含的东西比这个结构体多得多，我让 ai 给了一份内容：</p>
<p><img src="./process4.png" alt="" /></p>
<p>可以把 PCB 理解为“进程的档案袋”，然后这样一整套运行的程序信息，就被操作系统抽象为一个“<strong>进程</strong>”。</p>
<p>我整理为一段话就是：</p>
<blockquote>
<p>进程是正在运行的程序实例，而 PCB 是操作系统用来描述和管理这个进程的“档案”。</p>
</blockquote>
<p>PCB 中的一些关键点：</p>
<p>pid（进程的身份标识符）、内存管理信息（记录或指向进程的地址空间、页表等内存管理结构）、文件描述符表（记录打开了哪些文件/资源的表，这是在 Linux 中用叫法，Windows 常称句柄）、CPU 寄存器（保存进程当前正在使用的数据、地址和运算状态）......</p>
<h2>4. 进程是如何被 CPU 运行的</h2>
<p>前面说过，PCB 中会记录一个进程当前的状态。但一个进程从创建到结束，并不会一直占着 CPU 运行……实际上，在现如今的操作系统中，同一时间会存在上百甚至上千个进程，而 CPU 的核心数远没有那么多，就拿我的电脑举例：</p>
<p><img src="./process5.png" alt="" /></p>
<p>20 个内核，28 个逻辑处理器你可以理解为：</p>
<p>20 个牛马，其中一些牛马有两张工作台，因此公司排班中能看到 28 个工作位，也就是在干 28 个人的活</p>
<p>也就是说：</p>
<p><strong>想运行的进程很多，但真正能够同时在 CPU 上执行的进程数量是有限的。</strong></p>
<p>所以操作系统就得解决两个问题：</p>
<p>1.一个进程当前到底处于什么状态？</p>
<p>2.CPU 下一步该运行到哪个进程？</p>
<p>这两个问题就涉及到刚刚提到过一点的<strong>状态、调度和上下文切换</strong>了；</p>
<h3>4.1 进程的生命周期与状态</h3>
<p>同样先给出一个简化模型，有点模糊哈将就将就：</p>
<p><img src="./process6.png" alt="" /></p>
<p>以上是最常见的几个状态，最典型的两个就是<strong>就绪状态</strong>（Ready）和 <strong>阻塞状态</strong>（Blocked/Waiting）</p>
<p>就绪状态（Ready）</p>
<blockquote>
<p>什么都准备好了，可以理解为随叫随到</p>
</blockquote>
<p>运行状态（Running）</p>
<blockquote>
<p>当 CPU 真正开始执行这个进程中的指令时，进程就进入运行状态。</p>
</blockquote>
<p>阻塞状态（Blocked/Waiting）</p>
<blockquote>
<p>进程当前不适合到 cpu 上执行。</p>
</blockquote>
<p>不过在操作系统中，一般不区分就绪和执行，默认都是就绪。</p>
<p>举个例子：</p>
<blockquote>
<p>假设 Chrome 发起一次网络请求：</p>
<p>在等待服务器返回的时候，即使一直把 CPU 给 Chrome，那它也没有办法继续执行。所以操作系统会让它进入阻塞状态，把 CPU 让给其他可以运行的进程。</p>
</blockquote>
<p>这里的阻塞就可以理解为：CPU 给我也没用，我正在等别的东西。</p>
<blockquote>
<p>等到网络数据回来后，这个进程就重新具备运行条件，于是：</p>
<p>阻塞状态-&gt;等待事件完成-&gt;就绪状态</p>
<p>注意，它通常不是直接变成运行状态，而是先进入<strong>就绪状态</strong>。</p>
<p>因为即使它已经准备好了，CPU 可能仍然正在执行其他进程。</p>
<p>至于什么时候再次获得 CPU，还需要由操作系统决定。</p>
</blockquote>
<p>以上过程就是所说的 <strong>CPU 进程调度</strong>。</p>
<h3>4.2 CPU 如何选择下一个进程</h3>
<p>这一段的话，我就不是那么了解了，这部分就是借助 ai 理解了哈，如果有不对请纠正；</p>
<p>假设系统中同时存在这些进程：</p>
<blockquote>
<p>Chrome、IDEA、微信、MySQL、AppleMusic</p>
<p>并且以上都处于就绪状态，但当前只有一个 CPU 核心可以使用，此时该怎么办呢？</p>
</blockquote>
<p>当然不能放肆给大家一起抢了，不然你的电脑就崩溃了哈哈</p>
<p>所以需要到操作系统中的专门负责这一版块的组件了，称之为<strong>调度器</strong>（Scheduler）。</p>
<p>调度器可以简单理解为：</p>
<p><strong>从所有可以运行的进程中，选择一个进程获得 CPU。</strong></p>
<p>刚刚的假设呢，至于调度器要选谁，当然也不是随机的。操作系统还是会根据一些因素来决策，就比如：<strong>进程优先级</strong>、<strong>已经等待的时间</strong>、<strong>之前运行的时间</strong>、<strong>进程类型</strong>、<strong>系统调度策略</strong>...</p>
<p>关于这里我有一个例子有点意思：</p>
<blockquote>
<p>假设我是一个妹子~我长得好看又有才华，就有很多人追我~</p>
<p>有以下三个</p>
<p>A：有钱</p>
<p>B：帅</p>
<p>C：会添</p>
</blockquote>
<p>然后我同时和三个男朋友一起谈~</p>
<blockquote>
<p>为了让这三个人不能刚知道对方存在</p>
<p>我就规划好，周一二和 A 约会；周三四和 B 约会；周五六和 C 约会；周日留给自己；默认每个小哥哥都是“随叫随到”</p>
<p>当前就是分时复用，并发执行</p>
<p>定制时间表并且按照时间表来执行，就是进程调度的过程~</p>
</blockquote>
<p>当然这个例子只是用来理解“有限资源被多个任务轮流使用”，真正的 CPU 调度并没有一张提前写死的约会时间表，而是会根据任务状态、优先级和调度策略动态决定。</p>
<p>以上就涉及到 CPU 的一些调度算法了，关于调度算法这点我就不是很会啦~</p>
<p>不过只需要知道 <strong>CPU 不是某个进程永久占有的资源，而是由操作系统不断在多个可运行任务之间进行分配</strong>。</p>
<p>哦对了，这里还需要涉及一个概念——<strong>时间片</strong>（Time Slice / Quantum）。</p>
<p>又假如哈：</p>
<blockquote>
<p>操作系统让 Chrome 得到了 CPU，Chrome 当然也不会一直运行下去，通常是毫秒级的一小段 CPU 时间，具体长度取决于操作系统和调度策略。</p>
</blockquote>
<p>这段允许它连续使用 CPU 的时间，就可以理解为一个时间片。当时间片结束后，操作系统可能就会暂停 Chrome，然后让另一个进程运行，操作系统就是在不断的这样切换切换，让我们一直以为所有程序都在同时运行。当然，现代 CPU 通常有挺多个核心，所以也的确可以有多个任务在不同 CPU 核心上真正同时执行。</p>
<p><img src="./process7.png" alt="" /></p>
<p>不过你想，一个 CPU 的常规为 1 秒计算 21 亿次，而且还不是睿频，所以可以想象到 CPU 切换的速度。</p>
<p>然后呢，在同一时刻真正同时执行多个任务的情况，通常称为<strong>并行</strong>；而在单个 CPU 核心上快速交替执行多个任务，更接近于<strong>并发</strong>。</p>
<p>上述提到的不断来回切换这里就产生了一个问题，就是 CPU 怎么知道刚刚的程序执行到哪个步骤？CPU 的寄存器里的数据怎么办？显然，操作系统不能简单地“停下来就不管了”。</p>
<p>所以这就需要进行<strong>上下文切换</strong>。</p>
<h3>4.3 进程切换与上下文切换</h3>
<p>CPU 在执行一个进程时，会存在大量和当前执行状态有关的信息。</p>
<p>就比如有：程序计数器、CPU 寄存器、栈指针、部分处理器状态；</p>
<p>其中<strong>程序计数器</strong>（Program Counter，PC）非常重要，你可以把它理解为：CPU 下一条应该执行哪条指令。</p>
<p>就比如 Chrome 当前执行到：</p>
<pre><code>指令 100
指令 101
指令 102  ← 当前执行位置
指令 103
指令 104
</code></pre>
<p>这个时候 CPU 突然切换到正在做笔记的语雀，那么此时执行到了 102 的这个位置的信息就必须保存下来。所以在切换之前，操作系统会先保存当前进程的执行现场，然后被保存的这些运行信息，就是进程的“上下文”的一部分了。</p>
<p>我还有个例子哈，就是刚刚的三个小哥哥~</p>
<blockquote>
<p>我和 A 约会的时候，</p>
<p>A：下个月，你陪我去一趟三亚，你提前准备准备~</p>
<p>我：好的好的~</p>
</blockquote>
<blockquote>
<p>B：下个月，我的妈妈要过生日，想让你陪我去挑选一下礼物，提前准备准备~</p>
<p>我：好的好的~</p>
</blockquote>
<p>这个时候，当然不能出现我拿着一个金手镯子面对 A 问我准备如何；我当然也不能准备了一套性感的泳衣面对 B 问我准备如何啦。所以我就得在和 ABC 约会·的时候，每次结束都拿一个小本本把当天一些重要的事情记录下来，下次再和这个人约会时，就先翻一下小本子，回忆一下上次的进展。</p>
<p>这个例子还是很生动吧，然后这整个过程包括 Chrome 那个例子就是：<strong>上下文切换</strong>（Context Switch）。</p>
<p>这也就是为什么前面 PCB 中要保存：CPU 寄存器信息、程序计数器、进程状态、调度信息这些内容的原因了。</p>
<p>当然了，上下文切换也并不是免费的，毕竟切换这个过程还是有些性能开销的，就比如像是保存寄存器、恢复寄存器、切换相关内存管理信息、更新进程状态、执行调度逻辑...</p>
<p>不过到这里还有一个新问题，之前我们一直把一个进程看成是一个相对独立的运行单位，每个进程都有自己的地址空间和系统资源。</p>
<p>但是现实中的程序并不是完全互不相干的，就比如：</p>
<blockquote>
<p>浏览器进程要和渲染进程交换数据</p>
<p>客户端要和服务器通信</p>
<p>一个程序产生的数据可能要交给另一个程序处理</p>
</blockquote>
<p>我们又说不同进程之间默认相互隔离，那么<strong>一个进程要如何把数据交给另一个进程</strong>，就得需要继续了解<strong>进程之间如何交互协作</strong>了。</p>
<h2>5. 进程之间如何协作</h2>
<p>现在看，前面一直把一个进程看作一个相对独立的运行单位。</p>
<p>每个进程都有自己的运行状态、虚拟地址空间以及各种系统资源，这样的设计其实就是为了像是：</p>
<p>如果其中一个进程出现了问题，通常不会直接把另一个进程的内存也一起搞坏，抽象理解为：
<img src="./process8.png" alt="" /></p>
<p>就像是大家各住各的房子，正常情况下不能直接翻墙去别人家拿东西。这样的隔离可以提高可以提高系统的<strong>安全性</strong>和<strong>稳定性</strong>，但问题也随之出现。</p>
<p>我们上面提到过，现实中的程序并不是完全互不相干的，就比如浏览器：</p>
<p>当前 Chorme 一个进程需要多个进程一起完成浏览器工作</p>
<p><img src="./process9.png" alt="" /></p>
<p>当用户在地址栏输入一个网址，那么这个信息可能还需要交给其他进程处理，就比如：渲染进程可能需要知道页面内容是什么、GPU 相关进程可能需要知道有什么图形任务需要处理...</p>
<p>这些进程之间如果不能交流，显然用户就达不到他想要的结果。所以操作系统必须在“进程隔离”的基础上，再提供一些<strong>受控制的数据交换方式</strong>。这些机制统称为：<strong>进程间通信</strong>（Inter-Process Communication，IPC）。</p>
<p>这些机制可以倾向于这么理解：</p>
<blockquote>
<p>就是你们这些进程不能随便进对方家，但是我可以给你们提供电话、快递柜、公共会议室之类的方式进行交流。</p>
</blockquote>
<p>具体来说，操作系统提供了管道、共享内存、消息队列、Socket 等多种进程间通信方式，也就是前面说的 IPC。每一种方式具体怎么实现，还有不少内容，这里暂时先不展开。（其实是我不太懂了😁）</p>
<p>到这里其实已经能发现，<strong>进程的一个重要特点就是彼此之间相对独立</strong>。</p>
<p>独立当然有好处，一个进程出了问题不容易直接影响另一个进程，但这种独立也意味着：如果两个进程想共享数据，就得借助 IPC；创建和管理多个进程本身也需要一定的系统资源。</p>
<p>那么问题又来了。</p>
<p>假设现在不是两个独立程序需要协作，而是<strong>同一个程序内部需要同时做很多事情</strong>呢？</p>
<p>比如一个音乐软件可能需要同时：</p>
<blockquote>
<p>播放音乐</p>
<p>响应鼠标操作</p>
<p>更新界面</p>
<p>读取本地文件</p>
<p>请求网络数据</p>
</blockquote>
<p>如果每做一件事情都创建一个新的进程，显然有点太“重量级”了。</p>
<p>有没有一种比进程更加轻量，同时又能让一个程序内部存在多个执行任务的东西？</p>
<p>这就要继续说到——<strong>线程</strong>（Thread）。</p>
<h2>6. 为什么有了进程还需要线程</h2>
<p>从前面已经知道，一个进程可以看作一个正在运行的程序实例，它有自己的地址空间、系统资源，也可以被操作系统独立管理。<strong>但是</strong>，<strong>如果同一个程序内部，需要同时做很多事情怎么办？</strong></p>
<p>比如一个音乐软件运行时，可能需要同时：</p>
<blockquote>
<p>播放音乐</p>
<p>响应鼠标点击</p>
<p>更新界面</p>
<p>读取文件</p>
<p>请求网络数据</p>
</blockquote>
<p>当然，我们可以给每一个任务都创建一个新的进程，比如：</p>
<blockquote>
<p>播放音乐进程</p>
<p>界面更新进程</p>
<p>网络请求进程</p>
<p>文件读取进程</p>
</blockquote>
<p>但如果每个软件都这样需要这么多进程的话，实在是太“重”了。因为每创建一个新的进程，操作系统都要给它准备相对独立的地址空间以及各种管理信息；另外不同进程之间如果需要交换数据，还要借助前面提到的 IPC。</p>
<p>如果这些任务本来就同属一个程序，还每做一件事情就创建一个新进程，显然不太划算。</p>
<p>所以就出现了一个比进程更加轻量的执行单位：<strong>线程</strong>（Thread）</p>
<p>注意：进程是操作系统资源分配的基本单位；线程是 CPU 上调度执行的基本单位；</p>
<p>相比于进程：进程更像是一个程序的运行环境，而线程则是在这个环境中真正执行任务的执行流。</p>
<p>继续用前面的“房子”来比喻的话：</p>
<blockquote>
<p>进程 = 一套房子</p>
<p>线程 = 住在房子里干活的人</p>
</blockquote>
<p>不同进程就像不同的房子，彼此之间默认相互隔离。</p>
<p>而同一个进程中的多个线程，则像住在同一套房子里的人，它们可以共同使用客厅、厨房、冰箱这些公共资源，但每个人又可以同时做自己的事情。</p>
<p>我再换个比喻，这个可能会更生动些：</p>
<ol>
<li>两个老铁 两个酒桌 两套房间</li>
</ol>
<blockquote>
<p>我现在有 100 只鸡，需要人在房间的酒桌上吃完</p>
<p>此时如果有两个人，就得需要搞两套房间，两个酒桌，每个人就可以只需要吃 50 只鸡，但需要的花销就比较大</p>
</blockquote>
<p><img src="./process10.png" alt="" /></p>
<ol>
<li>一个房间 一个酒桌 两个老铁来吃</li>
</ol>
<blockquote>
<p>两个人在一个酒桌一个房间，两人一起吃 100 只，效率仍然能大幅度提高，还减少了酒桌和房间的开销</p>
</blockquote>
<p><img src="./process11.png" alt="" /></p>
<ol>
<li>引入更多线程的话，就如下图嘿嘿</li>
</ol>
<p><img src="./process12.png" alt="" /></p>
<p>不过虽然提高了线程的数目，能够提高效率，但也不是“线性增长”。线程数目达到一定程度之后，就算线程再多，也没法起到效果了。毕竟...桌子就那么大啦</p>
<p>线程数目太多的话，线程的调度开销的也会非常明显，从而拖慢了程序的性能。</p>
<p>所以：</p>
<p><strong>进程解决的是程序运行和资源隔离的问题，而线程解决的是一个进程内部如何同时存在多个执行任务的问题。</strong></p>
<p>而且严格来说，前面一直说“CPU 调度进程”主要是为了方便理解。现代操作系统中，真正被 CPU 调度执行的单位通常是线程。</p>
<p>至于线程到底是怎么创建的、多个线程是怎么同时运行的、为什么会出现线程安全问题，以及 Java 中的多线程到底是怎么回事，就留到下一篇继续说了。</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="操作系统"/>
  </entry>
  <entry>
    <title>JDBC 访问 MySQL：连接、预编译语句与事务处理</title>
    <link href="https://blog.syrena.cn/posts/database-jdbc/database-jdbc/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-jdbc/database-jdbc/</id>
    <published>2026-08-28T00:00:00.000Z</published>
    <updated>2026-08-28T00:00:00.000Z</updated>
    <summary>系统学习JDBC驱动配置、数据库连接、PreparedStatement、结果集与事务处理.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：Java 8 及以上、JDBC 4.2、MySQL 8.0.39。</p>
<p>该部分内容为了解，实际中数据库连接已被包装好，仅需调用即可</p>
</blockquote>
<h2>1. JDBC 是什么</h2>
<p>JDBC（Java Database Connectivity）<strong>是 Java 访问关系型数据库的一套标准 API</strong>，具体实现是由数据库厂商去完成的。它规定了连接数据库、发送 SQL、接收结果和处理异常的统一接口，真正与 MySQL 通信的代码则由 MySQL 驱动实现。</p>
<p>可以把三者的关系理解为：</p>
<pre><code>Java 程序 → JDBC 标准接口 → MySQL Connector/J 驱动 → MySQL 服务器
</code></pre>
<p>业务代码主要面向 <code>java.sql</code>、<code>javax.sql</code> 中的接口编程。更换数据库时虽然仍要调整驱动、地址和方言 SQL，但操作流程基本不变。</p>
<p>一次完整的 JDBC 操作通常包括：</p>
<ol>
<li>引入数据库驱动；</li>
<li>获取 <code>Connection</code> 连接；</li>
<li>定义 SQL，并创建 <code>PreparedStatement</code>；</li>
<li>为 SQL 中的占位符绑定参数；</li>
<li>执行 SQL；</li>
<li>处理执行结果：增删改读取影响行数，查询读取 <code>ResultSet</code>；</li>
<li>按照结果集、执行对象、数据库连接的顺序释放资源。</li>
</ol>
<p>如果多条增删改语句属于同一个业务，还要在同一个连接中统一提交或回滚事务。上面的顺序就是后文各节的编排顺序，也对应一段 JDBC 程序从开始到结束的实际执行过程。</p>
<hr />
<h2>2. 引入 MySQL 驱动</h2>
<h3>2.1 Maven 依赖</h3>
<p>maven：类似应用商店，在maven仓库中维护了所有的Java工程需要用到的依赖，IDEA中已经内置好了maven</p>
<p>由于我的mysql版本为8.4.xx，因此使用的maven对应的是下面这部分依赖；</p>
<p>不同版本可以到<a href="https://mvnrepository.com/search?q=mysql">Maven Repository: mysql</a>此处找对应版本依赖</p>
<p><img src="./jdbc0.png" alt="image-20260827191337560" /></p>
<p>第一个 MySQL Connector-j 为最新版本的依赖推荐</p>
<p><strong>IDEA创建模块选择maven后，在其下的pom.xml插入该依赖</strong></p>
<p><img src="./jdbc1.png" alt="image-20260827191835082" /></p>
<pre><code>&lt;dependency&gt;
    &lt;groupId&gt;com.mysql&lt;/groupId&gt;
    &lt;artifactId&gt;mysql-connector-j&lt;/artifactId&gt;
    &lt;version&gt;8.4.0&lt;/version&gt;
    &lt;scope&gt;compile&lt;/scope&gt;
&lt;/dependency&gt;
</code></pre>
<p>驱动必须进入运行时类路径，否则连接时会出现 <code>No suitable driver</code> 一类错误。</p>
<p><strong>如无法加载该依赖，则还需修改镜像源</strong></p>
<h3>2.2 配置国内镜像</h3>
<p>内置的maven路径：IDEA安装目录/plugins/maven/lib/maven3/conf/setting.xml</p>
<pre><code>&lt;!-- 加入如下 Mirror 节点，使用国内阿里云仓库镜像 开始 --&gt;
&lt;mirror&gt;
    &lt;id&gt;aliyunmaven&lt;/id&gt;
    &lt;mirrorOf&gt;*&lt;/mirrorOf&gt;
    &lt;name&gt;阿里云公共仓库&lt;/name&gt;
    &lt;url&gt;https://maven.aliyun.com/repository/public&lt;/url&gt;
&lt;/mirror&gt;

&lt;mirror&gt;
    &lt;id&gt;central&lt;/id&gt;
    &lt;mirrorOf&gt;*&lt;/mirrorOf&gt;
    &lt;name&gt;aliyun central&lt;/name&gt;
    &lt;url&gt;https://maven.aliyun.com/repository/central&lt;/url&gt;
&lt;/mirror&gt;

&lt;mirror&gt;
    &lt;id&gt;spring&lt;/id&gt;
    &lt;mirrorOf&gt;*&lt;/mirrorOf&gt;
    &lt;name&gt;aliyun spring&lt;/name&gt;
    &lt;url&gt;https://maven.aliyun.com/repository/spring&lt;/url&gt;
&lt;/mirror&gt;
&lt;!-- 加入如下 Mirror 节点，使用国内阿里云仓库镜像 结束 --&gt;
</code></pre>
<h3>2.3 是否还要手动加载驱动</h3>
<p>旧代码常写 <code>Class.forName("com.mysql.cj.jdbc.Driver")</code>。从 JDBC 4 开始，驱动通常会自动注册，现代项目一般可以不用写。自动加载失败时，先检查运行时依赖；旧环境或特殊类加载器才可能显式调用。</p>
<hr />
<h2>3. 核心接口</h2>
<table>
<thead>
<tr>
<th>接口或类</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>DriverManager</code></td>
<td>根据 JDBC URL 获取连接，适合简单程序和入门练习</td>
</tr>
<tr>
<td><code>DataSource</code></td>
<td>对“连接来源”的抽象，更适合配置、容器和连接池环境</td>
</tr>
<tr>
<td><code>Connection</code></td>
<td>表示一次数据库会话，也负责事务控制</td>
</tr>
<tr>
<td><code>Statement</code></td>
<td>执行不带参数的静态 SQL，不适合拼接外部输入</td>
</tr>
<tr>
<td><code>PreparedStatement</code></td>
<td>预编译 SQL、绑定参数，是日常增删改查的首选</td>
</tr>
<tr>
<td><code>CallableStatement</code></td>
<td>调用存储过程，继承自 <code>PreparedStatement</code></td>
</tr>
<tr>
<td><code>ResultSet</code></td>
<td>保存查询结果，并通过游标逐行读取</td>
</tr>
<tr>
<td><code>SQLException</code></td>
<td>JDBC 操作异常，可取得状态码、错误码和错误信息</td>
</tr>
</tbody>
</table>
<p>这些对象多数是接口，变量也应尽量声明为接口类型，减少对具体驱动的依赖。</p>
<hr />
<h2>4. 建立数据库连接</h2>
<h3>4.1 JDBC URL</h3>
<p>MySQL 连接地址的基本格式是：</p>
<pre><code>jdbc:mysql://主机:端口/数据库?参数1=值1&amp;参数2=值2
</code></pre>
<p>本机练习可以写成：</p>
<pre><code>jdbc:mysql://127.0.0.1:3306/jdbc_demo?sslMode=DISABLED&amp;allowPublicKeyRetrieval=true
</code></pre>
<p>其中，<code>127.0.0.1:3306</code> 是主机和端口，<code>jdbc_demo</code> 是数据库。后两个参数用于<strong>关闭 TLS</strong>、<strong>允许获取认证公钥</strong>，只适合可信的本地环境。生产环境应配置 TLS 和严格的证书校验，不能为了消除报错而照搬这两个参数。</p>
<h3>4.2 使用 <code>DriverManager</code></h3>
<pre><code>import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public final class JdbcConfig {
    private static final String URL =
        "jdbc:mysql://127.0.0.1:3306/jdbc_demo"
        + "?sslMode=DISABLED&amp;allowPublicKeyRetrieval=true";
    private static final String USER = System.getenv("DB_USER");
    private static final String PASSWORD = System.getenv("DB_PASSWORD");

    // 私有构造方法，禁止随意创建JdbcConfig对象
    private JdbcConfig() {}
	
    // 建立数据库连接
    public static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(URL, USER, PASSWORD);
    }
}
</code></pre>
<p>由于方法都是静态方法，所以只需要使用JdbcConfig.function()调用即可，不需要创建对象。</p>
<p>账号和密码通过环境变量读取，避免直接写入源码并提交到仓库。应用程序也不应长期使用 <code>root</code>，应为它创建只拥有所需权限的独立账号。</p>
<h3>4.3 <code>DataSource</code> 与连接池</h3>
<p><strong><code>DataSource</code></strong> 是 JDBC 提供的一个<strong>标准接口</strong>，但<strong>不等于连接池</strong>。<code>MysqlDataSource</code> 是基础数据源；HikariCP 等连接池会实现或包装它。使用连接池后，<code>close()</code> 通常表示归还连接，仍要及时调用。</p>
<p><code>DataSource</code> 作用是：</p>
<blockquote>
<p>向程序提供 <code>Connection</code>，并把连接地址、用户名、密码等配置集中管理起来；</p>
</blockquote>
<p>使用方式为：</p>
<pre><code>Connection connection = dataSource.getConnection();
</code></pre>
<p>但使用连接池仍然需要close()：</p>
<pre><code>connection.close();
</code></pre>
<p><strong>连接池</strong>可以理解为一个专门存放数据库连接的“容器”。</p>
<p>程序启动后，连接池会提前建立多个连接：</p>
<pre><code>连接池
├── Connection 1：空闲
├── Connection 2：正在使用
├── Connection 3：空闲
└── Connection 4：空闲
</code></pre>
<p>比起 <code>DriverManager</code> 每次调用 <code>getConnection()</code>通常都要重新建立一次数据库连接，连接池够创建多个连接；可以避免频繁创建和关闭连接，节省开销。</p>
<hr />
<h2>5. 定义 SQL、创建执行对象与参数绑定</h2>
<p>不要把外部输入拼进 SQL，例如 <code>"... where name='" + inputName + "'"</code>。输入中的引号或逻辑表达式可能改变语句结构，形成 <strong>SQL 注入</strong>。</p>
<p>可以举个例子（<strong>SQL注入演示</strong>）：</p>
<pre><code>System.out.println("请输入学号：");
            String sno = scanner.next();
            String sql = "select id,name,sno,age,class_id from student_design2 where sno = '"+ sno +"'";
            // 5.执行SQL，获取查询结果
            resultSet = statement.executeQuery(sql);  
</code></pre>
<p>由于使用的是 <code>Scanner.next()</code> ,<code>Scanner.next()</code> 遇到空格就停止读取。</p>
<p>因此我们可以<strong>用别的表示方法绕过空格</strong>，比如 <code>/**/</code> ，如果是网页中注入的话，通过url可以有：</p>
<blockquote>
<p>%20 --代表url编码的空格，在空格过滤时可以代替。</p>
<p>%09--用于在输出或显示文本时在该位置产生一个固定的水平间距，类似于tab键。</p>
<p>%0a--代表换行符</p>
<p>%0b--用于在输出或显示文本时在该位置产生一个固定的垂直间距，类似于tab键。</p>
<p>%0d--回车换行</p>
<p>%a0--代表的是非断行空格</p>
<p>%00--%00代表的是ASCII码中的空字符</p>
</blockquote>
<p>都是之前学攻防的底子哈哈哈哈哈</p>
<p>因此可以这样注入：<code>'/**/or/**/1=1;#</code>，使得查询语句被这样闭合</p>
<blockquote>
<p><code>select id, name, sno, age, class_id from student_design2 where sno = ' sno ' or 1=1;#</code></p>
</blockquote>
<p>使用 <strong>#</strong> 将后续语句屏蔽，由于 <strong>1=1</strong> 恒成立，因此完成全查询</p>
<p><img src="./jdbc2.png" alt="image-20260828195923708" /></p>
<p><strong>所以应把 SQL 结构与参数值分开</strong>：</p>
<p>用预编译的方式 <code>PreparedStatement</code> 解决SQL注入问题</p>
<pre><code>String sql = "select id, name, age from student where name = ? and age &gt;= ?";

try (PreparedStatement ps = connection.prepareStatement(sql)) {
    ps.setString(1, inputName);
    ps.setInt(2, minAge);

    // 参数绑定完成后，在同一个 try 代码块中执行 SQL
}
</code></pre>
<p>这里的实际顺序是：先定义带 <code>?</code> 的 SQL，再通过连接创建 <code>PreparedStatement</code>，最后调用 <code>setXxx()</code> 绑定参数。绑定完成后，才能在同一个 <code>PreparedStatement</code> 上执行 SQL。</p>
<p>使用时要记住：</p>
<ul>
<li><code>?</code> 是参数占位符，编号从 <strong>1</strong> 开始；</li>
<li>占位符外不要再加引号，应写 <code>name = ?</code>，不能写 <code>name = '?'</code>；</li>
<li><code>setString</code>、<code>setInt</code>、<code>setBigDecimal</code> 等方法会按类型安全传值；</li>
<li>Java 值为 <code>null</code> 时可使用 <code>setNull(index, Types.INTEGER)</code>，或在驱动支持的情况下使用 <code>setObject</code>；</li>
<li>占位符只能代表“值”，不能代替表名、列名、<code>ASC/DESC</code> 等 SQL 结构。动态结构必须使用白名单选择，不能直接拼接外部输入。</li>
</ul>
<p><code>PreparedStatement</code> 既降低 SQL 注入风险，也让类型转换和重复执行更清晰，应作为业务代码的默认选择。</p>
<hr />
<h2>6. 执行 SQL 的三个方法</h2>
<table>
<thead>
<tr>
<th>方法</th>
<th>适用场景</th>
<th>返回值</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>executeQuery()</code></td>
<td><code>SELECT</code> 查询</td>
<td><code>ResultSet</code></td>
</tr>
<tr>
<td><code>executeUpdate()</code></td>
<td><code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code>，以及部分 DDL</td>
<td><code>int</code>，表示驱动报告的影响行数</td>
</tr>
<tr>
<td><code>execute()</code></td>
<td>结果类型事先不确定、可能返回多个结果</td>
<td><code>boolean</code>，<code>true</code> 表示当前结果为 <code>ResultSet</code></td>
</tr>
</tbody>
</table>
<p>日常代码应选含义最明确的方法。不要对查询调用 <code>executeUpdate()</code>，也不要为了省事全部使用 <code>execute()</code>。</p>
<p>调用存储过程时使用 <code>CallableStatement</code>，形如 <code>prepareCall("{call procedure_name(?, ?)}")</code>；输出参数先用 <code>registerOutParameter</code> 注册。</p>
<hr />
<h2>7. 增删改查写法</h2>
<p>以下代码假设 <code>student</code> 表含有自增主键 <code>id</code>、姓名 <code>name</code>、年龄 <code>age</code> 和创建时间 <code>created_at</code>。</p>
<h3>7.1 新增并取得自增主键</h3>
<pre><code>String sql = "insert into student(name, age) values (?, ?)";

try (Connection conn = JdbcConfig.getConnection();
     PreparedStatement ps = conn.prepareStatement(
         sql, Statement.RETURN_GENERATED_KEYS)) {

    ps.setString(1, "小林");
    ps.setInt(2, 20);

    int rows = ps.executeUpdate();
    if (rows == 1) {
        try (ResultSet keys = ps.getGeneratedKeys()) {
            if (keys.next()) {
                long newId = keys.getLong(1);
                System.out.println("新增编号：" + newId);
            }
        }
    }
}
</code></pre>
<p>只有创建语句时传入 <code>Statement.RETURN_GENERATED_KEYS</code>，执行后才能规范地读取生成的主键。</p>
<h3>7.2 删除</h3>
<pre><code>String sql = "delete from student where id = ?";

try (Connection conn = JdbcConfig.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setLong(1, 1L);

    int rows = ps.executeUpdate();
    System.out.println("删除行数：" + rows);
}
</code></pre>
<p>删除前要确认 <code>WHERE</code> 条件正确，并检查影响行数。没有 <code>WHERE</code> 条件的 <code>DELETE</code> 可能删除整张表中的记录。</p>
<h3>7.3 修改</h3>
<pre><code>String sql = "update student set age = ? where id = ?";
try (Connection conn = JdbcConfig.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setInt(1, 21);
    ps.setLong(2, 1L);
    int rows = ps.executeUpdate();
    System.out.println("修改行数：" + rows);
}
</code></pre>
<p>新增、删除和修改都使用 <code>executeUpdate()</code>，并通过返回的影响行数判断条件是否匹配、操作是否符合预期。</p>
<h3>7.4 查询</h3>
<pre><code>String sql = "select id, name, age from student where age &gt;= ?";

try (Connection conn = JdbcConfig.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setInt(1, 18);

    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            long id = rs.getLong("id");
            String name = rs.getString("name");
            int age = rs.getInt("age");
            System.out.println(id + "，" + name + "，" + age);
        }
    }
}
</code></pre>
<p>查询使用 <code>executeQuery()</code> 获得 <code>ResultSet</code>，然后移动游标并读取每一行。查询结果的具体读取规则见下一节。</p>
<hr />
<h2>8. 读取 <code>ResultSet</code></h2>
<p>查询刚返回时，游标位于第一行之前。每次调用 <code>next()</code>，游标向后移动一行；有数据时返回 <code>true</code>，越过最后一行时返回 <code>false</code>。所以多行查询使用 <code>while (rs.next())</code>，只期待一行时可以使用 <code>if (rs.next())</code>。</p>
<p>读取列时推荐使用列名或别名，可读性比数字下标更好。联表查询若出现重名列，可写 <code>c.name as class_name</code>，再通过 <code>rs.getString("class_name")</code> 读取。</p>
<p>常见类型对应关系：</p>
<table>
<thead>
<tr>
<th>MySQL 类型</th>
<th>推荐 Java 类型</th>
<th>常用方法</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>VARCHAR</code>、<code>TEXT</code></td>
<td><code>String</code></td>
<td><code>getString</code>、<code>setString</code></td>
</tr>
<tr>
<td><code>INT</code></td>
<td><code>int</code> / <code>Integer</code></td>
<td><code>getInt</code>、<code>setInt</code></td>
</tr>
<tr>
<td><code>BIGINT</code></td>
<td><code>long</code> / <code>Long</code></td>
<td><code>getLong</code>、<code>setLong</code></td>
</tr>
<tr>
<td><code>DECIMAL</code></td>
<td><code>BigDecimal</code></td>
<td><code>getBigDecimal</code>、<code>setBigDecimal</code></td>
</tr>
<tr>
<td><code>DATE</code></td>
<td><code>LocalDate</code></td>
<td><code>getObject(..., LocalDate.class)</code></td>
</tr>
<tr>
<td><code>DATETIME</code>、<code>TIMESTAMP</code></td>
<td><code>LocalDateTime</code></td>
<td><code>getObject(..., LocalDateTime.class)</code></td>
</tr>
<tr>
<td><code>BLOB</code></td>
<td><code>byte[]</code> 或流</td>
<td><code>getBytes</code>、<code>getBinaryStream</code></td>
</tr>
</tbody>
</table>
<p><code>getInt</code>、<code>getLong</code> 等基本类型方法遇到 SQL <code>NULL</code> 会返回 <code>0</code>，因此可在读取后调用 <code>wasNull()</code>；如果业务上需要区分“空值”和“数字 0”，应使用包装类型保存结果。金额不要用 <code>double</code>，应使用 <code>BigDecimal</code>。</p>
<hr />
<h2>9. 释放资源</h2>
<p>执行结果处理完毕后，应关闭 <code>ResultSet</code>、<code>Statement</code> 或 <code>PreparedStatement</code>、<code>Connection</code>。推荐使用 <strong>try-with-resources</strong>：</p>
<pre><code>String sql = "select id, name, age from student where id = ?";

try (Connection connection = JdbcConfig.getConnection();
     PreparedStatement ps = connection.prepareStatement(sql)) {

    ps.setLong(1, 1L);

    try (ResultSet rs = ps.executeQuery()) {
        if (rs.next()) {
            System.out.println(rs.getString("name"));
        }
    }
}
</code></pre>
<p>Java 会自动按照声明的相反顺序关闭资源，因此实际关闭顺序是：</p>
<ol>
<li><code>ResultSet</code>；</li>
<li><code>PreparedStatement</code>；</li>
<li><code>Connection</code>。</li>
</ol>
<p>这与手动在 <code>finally</code> 中“先关闭结果集，再关闭执行对象，最后关闭连接”的顺序相同。try-with-resources 比逐个判空关闭更简洁，也能在发生异常时可靠释放资源。使用连接池时，<code>connection.close()</code> 通常表示把连接归还连接池，仍然必须调用。</p>
<hr />
<h2>10. JDBC 事务</h2>
<p>新连接默认开启自动提交，即每条 SQL 执行成功后立即提交。一个业务动作包含多条 SQL 时，必须在<strong>同一个 <code>Connection</code></strong> 上关闭自动提交，再统一提交或回滚。</p>
<pre><code>public static void transfer(long fromId, long toId, BigDecimal amount)
        throws SQLException {
    try (Connection conn = JdbcConfig.getConnection()) {
        boolean oldAutoCommit = conn.getAutoCommit();
        try {
            conn.setAutoCommit(false);

            String debitSql = "update account set balance = balance - ? "
                            + "where id = ? and balance &gt;= ?";
            try (PreparedStatement debit = conn.prepareStatement(debitSql)) {
                debit.setBigDecimal(1, amount);
                debit.setLong(2, fromId);
                debit.setBigDecimal(3, amount);
                if (debit.executeUpdate() != 1) {
                    throw new SQLException("扣款失败：账号不存在或余额不足");
                }
            }

            String creditSql =
                "update account set balance = balance + ? where id = ?";
            try (PreparedStatement credit = conn.prepareStatement(creditSql)) {
                credit.setBigDecimal(1, amount);
                credit.setLong(2, toId);
                if (credit.executeUpdate() != 1) {
                    throw new SQLException("收款账号不存在");
                }
            }

            conn.commit();
        } catch (SQLException e) {
            conn.rollback();
            throw e;
        } finally {
            conn.setAutoCommit(oldAutoCommit);
        }
    }
}
</code></pre>
<p>关键是多条语句共用同一连接，全部成功才 <code>commit()</code>，任意一步失败都 <code>rollback()</code>。归还池前还要恢复原来的 <code>autoCommit</code>，避免影响下一位使用者；事务中途不能重新获取连接。</p>
<p>数据库表还必须使用支持事务的存储引擎，例如 InnoDB，否则 Java 代码写了回滚也达不到预期效果。</p>
<hr />
<h2>11. 异常排查</h2>
<p>排错时应保留 <code>SQLException</code> 的状态码、数据库错误码和信息：</p>
<pre><code>catch (SQLException e) {
    System.err.println("SQLState：" + e.getSQLState());
    System.err.println("错误码：" + e.getErrorCode());
    System.err.println("错误信息：" + e.getMessage());
    throw e;
}
</code></pre>
<table>
<thead>
<tr>
<th>现象</th>
<th>常见检查方向</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>No suitable driver</code></td>
<td>驱动依赖是否进入运行时、URL 是否以 <code>jdbc:mysql:</code> 开头</td>
</tr>
<tr>
<td><code>Access denied</code></td>
<td>用户名、密码、连接来源和 MySQL 授权是否匹配</td>
</tr>
<tr>
<td><code>Unknown database</code></td>
<td>数据库是否创建，URL 中名称是否拼错</td>
</tr>
<tr>
<td><code>Communications link failure</code></td>
<td>MySQL 是否启动，地址、端口、防火墙是否正确</td>
</tr>
<tr>
<td><code>Public Key Retrieval is not allowed</code></td>
<td>优先配置 TLS；可信本机可按需启用公钥获取</td>
</tr>
<tr>
<td>SQL 语法异常</td>
<td>在数据库客户端中验证 SQL，并检查字段、表名和占位符数量</td>
</tr>
</tbody>
</table>
<p>日志可记录 SQL 模板、SQLState 和错误码，但不能输出密码等敏感参数。异常也不能空处理，否则上层会误以为业务成功。</p>
<hr />
<h2>参考</h2>
<ul>
<li><a href="https://dev.mysql.com/doc/connector-j/en/connector-j-installing-maven.html">MySQL Connector/J：使用 Maven 安装</a></li>
<li><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.sql/java/sql/Connection.html">Java JDBC API：Connection</a></li>
<li><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.sql/java/sql/PreparedStatement.html">Java JDBC API：PreparedStatement 与 ResultSet</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 用户与权限：账号、授权、角色与安全实践</title>
    <link href="https://blog.syrena.cn/posts/database-userpermissions/database-userpermissions/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-userpermissions/database-userpermissions/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>系统学习MySQL用户账号、权限层级、授权与回收、角色管理及安全实践.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0.39。</p>
</blockquote>
<h2>1. 用户和权限</h2>
<p>MySQL 安装完成后通常会有管理账号 <code>root</code>。它适合安装、维护和授权，但不适合作为普通项目的连接账号。</p>
<p>MySQL 的访问控制可以分成两步：</p>
<ol>
<li><strong>身份验证</strong>：根据用户名、连接来源和密码确定能否登录。</li>
<li><strong>权限验证</strong>：登录成功后，每执行一条 SQL，MySQL 都会检查该账号是否拥有相应权限。</li>
</ol>
<p>所以，“密码正确”只表示可以连接，并不代表可以查询、插入或删除数据。</p>
<hr />
<h2>2. MySQL 账号的组成</h2>
<h3>2.1 账号不是单独的用户名</h3>
<p>MySQL 账号的完整格式为：</p>
<pre><code>'user_name'@'host_name'
</code></pre>
<ul>
<li><code>user_name</code>：用户名；</li>
<li><code>host_name</code>：允许该用户从什么主机连接。</li>
</ul>
<p>例如，下面是两个不同的账号：</p>
<pre><code>'app_user'@'localhost'
'app_user'@'192.168.1.10'
</code></pre>
<p>虽然用户名相同，但是是两个独立账号，密码和权限也可以不同。MySQL 按“用户名 + 主机”定位账号。</p>
<p>用户名和主机名要分别加引号：</p>
<pre><code>-- 正确
'app_user'@'localhost'

-- 错误理解：这是用户名 app_user@localhost，主机部分被省略
'app_user@localhost'
</code></pre>
<p><strong>省略主机</strong>等价于 <code>'user_name'@'%'</code>，允许从任意主机匹配。实际创建账号时应该明确写出主机。</p>
<h3>2.2 常见主机范围</h3>
<table>
<thead>
<tr>
<th>写法</th>
<th>含义</th>
<th>建议</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>'localhost'</code></td>
<td>只允许本机连接</td>
<td>本机应用优先使用</td>
</tr>
<tr>
<td><code>'192.168.1.10'</code></td>
<td>只允许指定 IP</td>
<td>范围明确，安全性较高</td>
</tr>
<tr>
<td><code>'192.168.1.0/24'</code></td>
<td>允许指定 IPv4 网段</td>
<td>MySQL 8.0.23 起支持 CIDR</td>
</tr>
<tr>
<td><code>'192.168.1.0/255.255.255.0'</code></td>
<td>使用 IPv4 地址和子网掩码</td>
<td>可用于限制网段</td>
</tr>
<tr>
<td><code>'%'</code></td>
<td>任意主机</td>
<td>范围过大，不建议直接使用</td>
</tr>
</tbody>
</table>
<p>MySQL 8.0.35 起，主机部分的 <code>%</code>、<code>_</code> 通配符已被弃用。MySQL 8.0.39 应优先使用明确 IP、主机名或 CIDR，并配合防火墙限制来源。</p>
<hr />
<h2>3. 查看用户和当前身份</h2>
<h3>3.1 查看已有账号</h3>
<p>用户信息保存在 <code>mysql</code> 系统数据库的授权表中。管理员可以查看常用账号属性：</p>
<pre><code>select
    user,
    host,
    plugin,
    account_locked,
    password_expired
from mysql.user;
</code></pre>
<p>字段含义如下：</p>
<ul>
<li><code>user</code>、<code>host</code>：共同组成完整账号；</li>
<li><code>plugin</code>：账号使用的认证插件；</li>
<li><code>account_locked</code>：账号是否被锁定；</li>
<li><code>password_expired</code>：密码是否已经过期；</li>
</ul>
<p><code>mysql.infoschema</code>、<code>mysql.session</code>、<code>mysql.sys</code> 等通常是 MySQL 自己使用的保留账号，不应随意改密或删除。也不建议直接对 <code>mysql.user</code> 执行 <code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code>；应使用专门的账号管理语句，让 MySQL 正确维护所有授权表和内存权限数据。</p>
<h3>3.2 <code>USER()</code> 与 <code>CURRENT_USER()</code></h3>
<pre><code>select user(), current_user();
</code></pre>
<p>二者含义不同：</p>
<ul>
<li><code>USER()</code>：客户端连接时提供的用户名，以及客户端来源；</li>
<li><code>CURRENT_USER()</code>：MySQL 最终用于身份验证和权限检查的账号。</li>
</ul>
<p>普通情况下两者看起来相同，但匿名账号匹配、代理用户等场景下可能不同。判断权限属于哪个账号，应以 <code>CURRENT_USER()</code> 为准。</p>
<h3>3.3 查看权限和账号定义</h3>
<pre><code>-- 查看当前账号的权限
show grants;

-- 查看指定账号的权限
show grants for 'app_user'@'localhost';

-- 查看账号的认证、密码策略和锁定状态等定义
show create user 'app_user'@'localhost';
</code></pre>
<p>如果结果中出现：</p>
<pre><code>grant usage on *.* to 'app_user'@'localhost';
</code></pre>
<p><code>USAGE</code> 不代表拥有全局操作权限，而表示账号存在，但在该层级没有实际权限。</p>
<hr />
<h2>4. 创建用户</h2>
<h3>4.1 基本语法</h3>
<pre><code>create user [if not exists]
    'user_name'@'host_name'
    identified by 'password';
</code></pre>
<p>示例：</p>
<pre><code>create user if not exists
    'sqllearn_app'@'localhost'
    identified by 'Study-MySQL_2026!';
</code></pre>
<p>执行后只完成三件事：创建账号、限制连接来源、设置登录密码。新账号默认没有业务数据库的访问权限，还需要单独执行 <code>GRANT</code>。</p>
<p><code>IF NOT EXISTS</code> 可避免账号已存在时报错，但不会修改已有账号。简单密码可能被密码策略拒绝。</p>
<p>MySQL 8.0 中推荐把创建和授权分开写：</p>
<pre><code>create user if not exists 'sqllearn_app'@'localhost'
identified by 'Study-MySQL_2026!';

grant select, insert, update, delete
on sqllearn.*
to 'sqllearn_app'@'localhost';
</code></pre>
<p>这样能清楚区分“创建身份”和“授予权限”。</p>
<h3>4.2 创建限定网段的账号</h3>
<pre><code>create user 'report_user'@'192.168.1.0/24'
identified by 'Report-Read_2026!';
</code></pre>
<p>该账号只能从匹配的 IPv4 网段连接。远程连接还取决于 MySQL 监听地址、防火墙和云服务器安全组。</p>
<hr />
<h2>5. 修改和删除用户</h2>
<h3>5.1 修改密码</h3>
<p>推荐使用 <code>ALTER USER</code>：</p>
<pre><code>alter user 'sqllearn_app'@'localhost'
identified by 'New-Study_2026!';
</code></pre>
<p>修改<strong>当前已认证账号</strong>的密码：</p>
<pre><code>-- USER() 是 ALTER USER 为当前账号提供的专用写法
alter user user()
identified by 'My-New-Password_2026!';
</code></pre>
<p>也可以使用 <code>SET PASSWORD</code>：</p>
<pre><code>-- 修改指定账号密码
set password for 'sqllearn_app'@'localhost'
= 'New-Study_2026!';

-- 修改当前账号密码
set password = 'My-New-Password_2026!';
</code></pre>
<p>优先掌握 <code>ALTER USER</code>，因为它还能管理账号锁定和密码过期等属性。</p>
<h3>5.2 锁定、解锁和要求改密</h3>
<pre><code>-- 暂停账号登录，但保留账号和权限
alter user 'sqllearn_app'@'localhost' account lock;

-- 恢复账号登录
alter user 'sqllearn_app'@'localhost' account unlock;

-- 要求用户下次连接时修改密码
alter user 'sqllearn_app'@'localhost' password expire;
</code></pre>
<p>暂时停用时先锁定，确认废弃后再删除，便于恢复。</p>
<h3>5.3 重命名和删除账号</h3>
<pre><code>rename user
    'sqllearn_app'@'localhost'
to  'sqllearn_service'@'localhost';
</code></pre>
<p><code>RENAME USER</code> 会保留旧账号的权限。删除语法为：</p>
<pre><code>drop user if exists 'sqllearn_service'@'localhost';
</code></pre>
<p><code>DROP USER</code> 会删除账号及其授权记录，但不会删除它创建的数据库和表。删除前应检查它是否是视图、触发器或存储程序的 <code>DEFINER</code>。已有连接也不会被强制立即断开。</p>
<hr />
<h2>6. 权限类型和作用层级</h2>
<h3>6.1 常用权限</h3>
<table>
<thead>
<tr>
<th>权限</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>SELECT</code></td>
<td>查询数据</td>
</tr>
<tr>
<td><code>INSERT</code></td>
<td>插入数据</td>
</tr>
<tr>
<td><code>UPDATE</code></td>
<td>修改数据</td>
</tr>
<tr>
<td><code>DELETE</code></td>
<td>删除数据</td>
</tr>
<tr>
<td><code>CREATE</code></td>
<td>创建数据库或表，具体能力取决于授权层级</td>
</tr>
<tr>
<td><code>ALTER</code></td>
<td>修改表结构</td>
</tr>
<tr>
<td><code>DROP</code></td>
<td>删除数据库、表或视图；<code>TRUNCATE TABLE</code> 也需要该权限</td>
</tr>
<tr>
<td><code>INDEX</code></td>
<td>创建或删除索引</td>
</tr>
<tr>
<td><code>CREATE VIEW</code></td>
<td>创建或修改视图</td>
</tr>
<tr>
<td><code>SHOW VIEW</code></td>
<td>查看视图定义</td>
</tr>
<tr>
<td><code>EXECUTE</code></td>
<td>执行存储过程或函数</td>
</tr>
<tr>
<td><code>ALL PRIVILEGES</code></td>
<td>指定层级内的全部可用权限，不包含 <code>GRANT OPTION</code> 和 <code>PROXY</code></td>
</tr>
</tbody>
</table>
<p><code>FILE</code>、<code>CREATE USER</code>、<code>PROCESS</code> 等影响范围较大，普通应用通常不需要。</p>
<h3>6.2 权限层级</h3>
<p>同一种权限可以授予不同范围：</p>
<table>
<thead>
<tr>
<th>授权对象</th>
<th>层级</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>*.*</code></td>
<td>全局级</td>
<td>整个 MySQL 服务中的所有数据库</td>
</tr>
<tr>
<td><code>sqllearn.*</code></td>
<td>数据库级</td>
<td><code>sqllearn</code> 中现有及以后创建的对象</td>
</tr>
<tr>
<td><code>sqllearn.student</code></td>
<td>表级</td>
<td>只作用于 <code>student</code> 表</td>
</tr>
<tr>
<td><code>SELECT(name, age)</code></td>
<td>列级</td>
<td>只能查询指定列</td>
</tr>
<tr>
<td><code>PROCEDURE sqllearn.p_demo</code></td>
<td>存储过程级</td>
<td>只作用于指定过程</td>
</tr>
</tbody>
</table>
<p>范围越大，风险越高。应用账号一般使用数据库级或更小的范围，不应轻易授权 <code>*.*</code>。</p>
<p><code>GRANT ALL ON sqllearn.*</code> 只表示拥有 <code>sqllearn</code> 数据库层级能够授予的全部权限，不会因此获得 <code>FILE</code>、<code>CREATE USER</code> 等全局管理权限。</p>
<hr />
<h2>7. 授予权限</h2>
<h3>7.1 基本语法</h3>
<pre><code>grant priv_type [, priv_type ...]
on priv_level
to 'user_name'@'host_name'
[with grant option];
</code></pre>
<p><code>priv_type</code>：权限类型</p>
<p><code>priv_level</code>：权限作用范围，如数据库、某数据库中的某表...</p>
<p><code>with grant option</code>：允许该用户把自己拥有的权限继续授权给其他用户</p>
<p>示例：授予 <code>sqllearn</code> 数据库的常用增删改查权限。</p>
<pre><code>grant select, insert, update, delete
on sqllearn.*
to 'sqllearn_app'@'localhost';
</code></pre>
<p>创建只读账号：</p>
<pre><code>create user 'sqllearn_read'@'localhost'
identified by 'Read-Only_2026!';

grant select
on sqllearn.*
to 'sqllearn_read'@'localhost';
</code></pre>
<p>在<code>select</code>权限下执行<code>insert</code>操作会有如下报错：</p>
<p><img src="./userPermissions0.png" alt="" /></p>
<p>限制到一张表：</p>
<pre><code>grant select
on sqllearn.student
to 'sqllearn_read'@'localhost';
</code></pre>
<p>限制到部分列：</p>
<pre><code>grant select (id, name, age)
on sqllearn.student
to 'sqllearn_read'@'localhost';
</code></pre>
<h3>7.2 <code>WITH GRANT OPTION</code></h3>
<pre><code>grant select
on sqllearn.*
to 'team_leader'@'localhost'
with grant option;
</code></pre>
<p>该选项还允许 <code>team_leader</code> 把这项权限授予其他账号。普通业务用户不需要这种能力。</p>
<h3>7.3 权限何时生效</h3>
<p>通过 <code>CREATE USER</code>、<code>GRANT</code>、<code>REVOKE</code>、<code>ALTER USER</code> 等专用语句修改账号时，MySQL 会主动更新权限数据，<strong>通常不需要执行 <code>FLUSH PRIVILEGES</code></strong>。</p>
<p><code>FLUSH PRIVILEGES</code>表示<strong>刷新权限操作</strong>；</p>
<p>已有连接中不同层级的变化可能在不同时间体现：</p>
<ul>
<li>表级、列级权限通常<strong>在下一条请求时</strong>体现；</li>
<li>数据库级权限可能要再次执行 <code>USE 数据库名</code>；</li>
<li>静态全局权限或密码变化通常要重新连接后体现。</li>
</ul>
<p>若授权后仍报错，先确认授权账号与 <code>CURRENT_USER()</code> 是否一致，再根据层级重新选择数据库或连接。</p>
<p><code>FLUSH PRIVILEGES</code> 主要用于直接修改授权表后重新加载，或 <code>--skip-grant-tables</code> 启动后的恢复；直接修改授权表并不推荐。</p>
<hr />
<h2>8. 回收权限</h2>
<h3>8.1 基本语法</h3>
<pre><code>revoke [if exists] priv_type [, priv_type ...]
on priv_level
from 'user_name'@'host_name';
</code></pre>
<p>回收写权限，只保留原有查询权限：</p>
<pre><code>revoke insert, update, delete
on sqllearn.*
from 'sqllearn_app'@'localhost';
</code></pre>
<p>回收 <code>sqllearn</code> 数据库上的全部权限：</p>
<pre><code>revoke all privileges
on sqllearn.*
from 'sqllearn_app'@'localhost';
</code></pre>
<p>清除该账号在所有层级的权限和转授权能力：</p>
<pre><code>revoke all privileges, grant option
from 'sqllearn_app'@'localhost';
</code></pre>
<p>注意：</p>
<ul>
<li><code>REVOKE</code> 只撤销权限，不删除账号；账号仍可能登录，但无法执行未获授权的操作。</li>
<li>只想撤销某个数据库的权限时，应写 <code>sqllearn.*</code>，不要误写成范围更大的全局清除语句。</li>
<li><code>REVOKE IF EXISTS</code> 从 MySQL 8.0.30 起支持。</li>
<li>撤权后使用 <code>SHOW GRANTS</code> 核对结果，不要只根据 <code>SHOW DATABASES</code> 判断。</li>
</ul>
<p>区分<strong>授予</strong>和<strong>回收</strong>语法：</p>
<p>授予</p>
<pre><code>grant ... to 用户;
</code></pre>
<p>回收</p>
<pre><code>revoke ... from 用户;
</code></pre>
<hr />
<h2>9. 使用角色管理权限</h2>
<p>许多用户需要同一组权限时，可以把权限封装成角色，再分配给用户。</p>
<pre><code>-- 1. 创建角色
create role 'r_sqllearn_read';

-- 2. 给角色授权
grant select
on sqllearn.*
to 'r_sqllearn_read';

-- 3. 创建实际登录账号
create user 'analyst'@'localhost'
identified by 'Analyst-Read_2026!';

-- 4. 把角色授予用户；授予角色时没有 ON 子句
grant 'r_sqllearn_read'
to 'analyst'@'localhost';

-- 5. 设置为默认激活角色
set default role 'r_sqllearn_read'
to 'analyst'@'localhost';
</code></pre>
<p>查看当前会话已经激活的角色：</p>
<pre><code>select current_role();
</code></pre>
<p>角色适合账号较多、权限模板相同的场景。少量账号直接 <code>GRANT</code> 即可。</p>
<hr />
<h2>10. 常见错误</h2>
<h3>10.1 <code>Access denied for user</code>（1045）</h3>
<p>表示连接阶段失败，优先检查：</p>
<ol>
<li>用户名或密码是否正确；</li>
<li>连接来源是否匹配账号的 <code>host</code>；</li>
<li>使用的是否是预期账号，例如 <code>'user'@'localhost'</code> 与 <code>'user'@'%'</code> 并非同一个账号；</li>
<li>客户端连接的是不是预期 MySQL 服务器和端口。</li>
</ol>
<h3>10.2 <code>Access denied ... to database</code>（1044）</h3>
<p>账号已经登录，但没有目标数据库的访问权限。使用管理员账号执行：</p>
<pre><code>show grants for 'user_name'@'host_name';
</code></pre>
<p>检查授权数据库名和账号主机是否准确。</p>
<h3>10.3 <code>command denied ... for table</code>（1142）</h3>
<p>说明账号能够访问数据库，但缺少当前命令所需权限。例如能 <code>SELECT</code> 却不能 <code>INSERT</code>，就需要检查是否授予 <code>INSERT</code>，而不是重新设置密码。</p>
<h3>10.4 授权后仍未生效</h3>
<p>依次检查：</p>
<pre><code>select current_user();
show grants;
</code></pre>
<p>如果账号正确，再尝试重新执行 <code>USE 数据库名</code> 或重新连接。使用专用授权语句时不应把 <code>FLUSH PRIVILEGES</code> 当成固定步骤。</p>
<hr />
<h2>参考</h2>
<pre><code>flowchart LR
    A["用户和权限管理"]

    A --&gt; U["用户"]
    U --&gt; U1["mysql.user"]
    U --&gt; U2["'user'@'host'"]
    U --&gt; U3["CREATE USER"]
    U --&gt; U4["ALTER USER"]
    U --&gt; U5["SET PASSWORD"]
    U --&gt; U6["DROP USER"]

    A --&gt; P["权限"]

    P --&gt; G["GRANT"]
    G --&gt; G1["SELECT"]
    G --&gt; G2["多个权限"]
    G --&gt; G3["ALL"]
    G --&gt; G4["WITH GRANT OPTION"]

    P --&gt; S["权限范围"]
    S --&gt; S1["*.*"]
    S --&gt; S2["db_name.*"]
    S --&gt; S3["db_name.table_name"]

    P --&gt; SG["SHOW GRANTS"]
    SG --&gt; SG1["USAGE"]

    P --&gt; R["REVOKE"]
    R --&gt; R1["回收权限"]
</code></pre>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/account-names.html">MySQL 8.0：账号名称</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/creating-accounts.html">MySQL 8.0：创建与管理账号</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/grant.html">MySQL 8.0：GRANT</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/revoke.html">MySQL 8.0：REVOKE</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/privilege-changes.html">MySQL 8.0：权限变化何时生效</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/roles.html">MySQL 8.0：角色</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 视图：定义、更新规则与执行算法</title>
    <link href="https://blog.syrena.cn/posts/database-view/database-view/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-view/database-view/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>系统学习MySQL视图的创建、查询、更新、检查选项、处理算法与权限控制.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0。</p>
<p>视图可以理解为“有名字、可重复查询的 <code>SELECT</code>”，主要用于封装查询、限制可见数据和提供稳定的查询接口</p>
</blockquote>
<h2>1. 视图</h2>
<p>视图（View）是一张虚拟表，它的内容来自一个或多个基表、其他视图或表达式的查询结果；
创建视图时，MySQL 主要保存的是视图名称、列信息和 <code>SELECT</code> 定义，<strong>而不是另存一份结果数据</strong></p>
<pre><code>flowchart LR
    Q["查询视图"] --&gt; V["执行视图定义"]
    V --&gt; S["student 基表"]
    V --&gt; C["score 基表"]
</code></pre>
<p>视图有以下特点：</p>
<ul>
<li>查询视图时，MySQL <strong>根据视图定义读取</strong>当时的基表数据；</li>
<li>基表数据改变后，再次查询视图通常会<strong>看到新结果</strong>；</li>
<li>删除视图只删除查询定义，不会删除基表及其数据；</li>
<li>普通视图<strong>不是数据副本、备份或快照</strong>，也不会自动提高查询速度；</li>
<li>视图定义仍会保存在数据字典中，所以“视图不存结果数据”不等于完全不占任何空间。</li>
</ul>
<p>例如，下面的视图只展示学生编号、姓名和年龄：</p>
<pre><code>create view v_student_basic as
select id, name, age
from student;
</code></pre>
<p>查询视图与查询普通表的写法相同：</p>
<pre><code>select * from v_student_basic;
</code></pre>
<hr />
<h2>2. 视图优点</h2>
<p><strong>简化复杂查询</strong></p>
<blockquote>
<p>多表连接、筛选和计算可以封装到视图中。应用以后只查询视图，不必反复编写同一段复杂 SQL。</p>
</blockquote>
<p><strong>限制可见的行和列</strong></p>
<blockquote>
<p>视图可以不暴露密码、身份证号等敏感列，也可以通过 <code>WHERE</code> 只展示某些行。但这只有与权限控制结合才真正安全：如果用户仍拥有基表的 <code>SELECT</code> 权限，他依然可以绕过视图直接查询基表。</p>
</blockquote>
<p><strong>提供相对稳定的查询接口</strong></p>
<blockquote>
<p>应用统一查询视图。底层表调整后，有时只需重新定义视图即可保持视图的列名不变，减少应用改动。但这种独立性并非绝对：删除视图依赖的列仍可能使视图失效。</p>
</blockquote>
<p><strong>统一名称和业务口径</strong></p>
<blockquote>
<p>视图可以为列起更清楚的名称，并把“总分如何计算”“有效记录如何筛选”等规则集中在一处，避免不同程序写出不同口径。</p>
</blockquote>
<hr />
<h2>3. 创建视图</h2>
<h3>3.1 基本语法</h3>
<pre><code>create view 视图名 [(视图列名列表)] as
select 查询列
from 表名
[where 条件];
</code></pre>
<p>较完整的 MySQL 语法为：</p>
<pre><code>create [or replace]
    [algorithm = {undefined | merge | temptable}]
    [definer = 用户]
    [sql security {definer | invoker}]
view 视图名 [(视图列名列表)] as
select_statement
[with [cascaded | local] check option];
</code></pre>
<ul>
<li>
<p>algorithm：这个是视图执行算法，MySQL 查询视图时，有三种处理方式。undefined（默认）、merge（合并）、tempable（临时表）</p>
</li>
<li>
<p>definer：指定创建这个视图的人。如<code>definer='root'@'localhost'</code>表示该视图属于root用户</p>
</li>
<li>
<p>sql security：表示查询视图时，使用谁的权限</p>
<ul>
<li>definer 则表示使用创建视图用户的权限；</li>
<li>invoker 则表示当前查询用户自己的权限；</li>
</ul>
</li>
<li>
<p>with check option：防止通过视图修改出视图范围之外的数据</p>
<ul>
<li>cascaded：检查所有关联视图；</li>
<li>local：只检查当前视图；</li>
</ul>
</li>
</ul>
<p><strong>示例</strong>：</p>
<pre><code>create or replace
algorithm = merge
definer = 'root'@'localhost'
sql security definer
view v_java_student (student_id, student_name, class_name)
as
select
    s.id,
    s.name,
    c.name
from student_design2 s
join class_design2 c
    on c.id = s.class_id
where c.name = 'Java001班'
with cascaded check option;
</code></pre>
<h3>3.2 使用别名确定视图列名</h3>
<p>以下示例连接学生、班级、课程和成绩表。显式 <code>JOIN ... ON ...</code> 能把连接条件与普通筛选条件分开，比逗号连接更容易阅读，也能减少漏写连接条件造成笛卡尔积的风险。</p>
<pre><code>create view v_student_score as
select
    s.id          as student_id,
    s.name        as student_name,
    s.sno,
    s.age,
    s.gender,
    s.enroll_date,
    c.id          as class_id,
    c.name        as class_name,
    co.id         as course_id,
    co.name       as course_name,
    sc.id         as score_id,
    sc.score
from student s
join class c
    on c.id = s.class_id
join score sc
    on sc.student_id = s.id
join course co
    on co.id = sc.course_id;
</code></pre>
<p>来自不同表的列可能同名，例如四张表都可能有 <code>id</code>。视图中的列名必须唯一，所以要用 <code>AS</code> 改成 <code>student_id</code>、<code>class_id</code> 等明确名称。</p>
<h3>3.3 在视图名后指定列名</h3>
<p>也可以统一列出视图的列名：</p>
<pre><code>create view v_student_name_age
    (student_id, student_name, student_age) as
select id, name, age
from student;
</code></pre>
<p>括号中的名称数量必须与 <code>SELECT</code> 返回的列数完全相同。两种命名方法选择一种即可；复杂查询通常使用 <code>AS</code> 别名更直观，因为名称紧挨对应表达式。</p>
<h3>3.4 创建时的注意事项</h3>
<ol>
<li>视图与表属于同一数据库名称空间，不能在同一数据库中同名。</li>
<li>建议明确写出字段，不要长期依赖 <code>SELECT *</code>。视图定义在创建时确定；基表以后新增列，不会自动加入既有视图。</li>
<li>如果基表的依赖列被删除或改名，查询视图可能报错，需要重新定义视图。</li>
<li>不要依赖视图定义中的 <code>ORDER BY</code> 保证顺序。查询视图时应在最外层明确排序；外层自己的 <code>ORDER BY</code> 会取代视图内的排序。</li>
<li><code>CREATE VIEW</code> 是 DDL，会触发隐式提交，不要把它混入需要回滚的业务事务。</li>
</ol>
<hr />
<h2>4. 查询和使用视图</h2>
<p>视图可出现在普通表能够出现的许多查询位置，并可继续筛选、连接、分组和排序：</p>
<pre><code>-- 查询全部视图数据
select *
from v_student_score;

-- 对视图结果继续筛选和排序
select student_name, course_name, score
from v_student_score
where score &gt;= 90
order by score desc;

-- 视图与真实表连接
select v.student_name, v.course_name, v.score, s.enroll_date
from v_student_score v
join student s
    on s.id = v.student_id;
</code></pre>
<p>视图也可以隐藏查询细节。例如只对外提供姓名和总分：</p>
<pre><code>create view v_student_total_points as
select
    s.id as student_id,
    s.name as student_name,
    sum(sc.score) as total_points
from student s
join score sc
    on sc.student_id = s.id
group by s.id, s.name;
</code></pre>
<pre><code>select student_name, total_points
from v_student_total_points
order by total_points desc;
</code></pre>
<p>用户只能从该视图获得定义中已有的列，不能临时查询未被视图暴露的学号或各科明细。若确实需要这些字段，应修改视图、另建视图或在有权限时查询基表。</p>
<hr />
<h2>5. 视图与基表数据的关系</h2>
<h3>5.1 修改基表会影响视图结果</h3>
<pre><code>update score
set score = 99
where student_id = 1 and course_id = 1;

select *
from v_student_score
where student_id = 1 and course_id = 1;
</code></pre>
<p><code>UPDATE</code> 修改的是 <code>score</code> 基表。视图没有独立保存旧结果，所以再次查询时会显示修改后的成绩。</p>
<h3>5.2 修改可更新视图会影响基表</h3>
<p>创建一个行与基表行一一对应的简单视图：</p>
<pre><code>create view v_class_one_student as
select id, name, age, class_id
from student
where class_id = 1;
</code></pre>
<pre><code>update v_class_one_student
set age = 20
where id = 1;
</code></pre>
<p>如果这个视图满足可更新条件，这条语句最终修改的是 <code>student</code> 基表中 <code>id=1</code> 的记录。因此能看出，视图不是基表的副本；通过视图写数据同样需要<strong>事务、权限和条件控制</strong>。</p>
<hr />
<h2>6. 可更新视图</h2>
<p>视图能够被 <code>UPDATE</code>、<code>DELETE</code> 或 <code>INSERT</code> 操作的核心条件是：
视图中的一行能够明确对应到底层表中的一行。
最容易更新的是“单表 + 简单列 + 普通 <code>WHERE</code>”视图。</p>
<p>以下结构<strong>通常</strong>会使视图不可更新：</p>
<ul>
<li>聚合函数或窗口函数，如 <code>SUM()</code>、<code>COUNT()</code>、<code>AVG()</code>；</li>
<li><code>DISTINCT</code>；</li>
<li><code>GROUP BY</code>、<code>HAVING</code>；</li>
<li><code>UNION</code>、<code>UNION ALL</code>；</li>
<li>查询列表中的子查询；</li>
<li>某些多表连接；</li>
<li>在 <code>FROM</code> 中引用不可更新视图；</li>
<li>只查询常量，没有可对应的基表行；</li>
<li>明确使用 <code>ALGORITHM = TEMPTABLE</code>。</li>
</ul>
<p>例如 <code>v_student_total_points</code> 使用了 <code>SUM()</code> 和 <code>GROUP BY</code>。
一条总分记录由多条成绩记录合成，MySQL 无法判断“把总分改成 500”应当修改哪一科，所以它只适合查询。</p>
<p><code>ORDER BY</code> 可以出现在视图定义中，但不能把它简单记成“只要有 <code>ORDER BY</code>，视图就一定不可更新”。可更新性取决于完整定义和处理方式；为了职责清楚，可写视图通常不在内部排序，而在查询视图时排序。</p>
<p>“可更新”也不一定代表“可插入”。通过视图插入时，视图还要能为基表中所有没有默认值的必填列提供值，而且目标列通常必须是简单的基表列引用。</p>
<p>检查 MySQL 记录的可更新状态：</p>
<pre><code>select table_name, is_updatable
from information_schema.views
where table_schema = database();
</code></pre>
<p><code>IS_UPDATABLE='YES'</code> 表示该视图可用于某些更新操作，不表示任意 <code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code> 都必然合法；实际操作还受列、连接方式和权限等条件限制。</p>
<hr />
<h2>7. WITH CHECK OPTION</h2>
<p>普通可更新视图有一个容易忽略的问题：通过视图修改数据后，新数据可能不再满足视图的 <code>WHERE</code> 条件，于是该行会从视图中“消失”。</p>
<pre><code>create view v_class_one_student as
select id, name, age, class_id
from student
where class_id = 1;

-- 若没有检查选项，这次修改可能成功，随后该行不再出现在视图中
update v_class_one_student
set class_id = 2
where id = 1;
</code></pre>
<p>在可更新视图后加入 <code>WITH CHECK OPTION</code>，可以阻止通过该视图写入不再满足视图条件的数据：</p>
<pre><code>create or replace view v_class_one_student as
select id, name, age, class_id
from student
where class_id = 1
with check option;
</code></pre>
<p>此时把 <code>class_id</code> 改为 <code>2</code> 会失败，因为修改后的记录不符合 <code>class_id=1</code>。
它既检查 <code>UPDATE</code> 后的行，也检查通过视图 <code>INSERT</code> 的行。</p>
<p>视图基于其他视图时还可指定检查范围：</p>
<ul>
<li><code>WITH LOCAL CHECK OPTION</code>：检查当前视图的条件，并按下层视图原有的检查设置继续处理；</li>
<li><code>WITH CASCADED CHECK OPTION</code>：检查当前视图及所有下层视图的条件；</li>
<li>不写 <code>LOCAL</code> 或 <code>CASCADED</code> 时，默认是 <code>CASCADED</code>。</li>
</ul>
<p>没有嵌套视图时，直接写 <code>WITH CHECK OPTION</code> 最容易理解。</p>
<hr />
<h2>8. 修改、查看与删除视图</h2>
<h3>8.1 修改定义</h3>
<pre><code>create or replace view v_student_basic as
select id, name, age, gender
from student;
</code></pre>
<p><code>CREATE OR REPLACE VIEW</code> 在视图不存在时创建，在已存在时替换。也可以使用：</p>
<pre><code>alter view v_student_basic as
select id, name, age, gender
from student;
</code></pre>
<p><code>ALTER VIEW</code> 要求目标视图已经存在。两种方式都是重新定义视图，不会直接修改基表数据；它们属于 DDL，同样可能隐式提交当前事务。</p>
<h3>8.2 查看视图</h3>
<pre><code>-- 查看当前数据库中的视图
show full tables where table_type = 'VIEW';

-- 查看完整创建语句，排查算法、安全模式和检查选项
show create view v_student_basic;

-- 查看视图对外提供的列
desc v_student_basic;

-- 检查视图依赖是否仍然有效
check table v_student_basic;
</code></pre>
<p>还可查询更完整的元数据：</p>
<pre><code>select
    table_name,
    is_updatable,
    check_option,
    security_type
from information_schema.views
where table_schema = database();
</code></pre>
<h3>8.3 删除视图</h3>
<pre><code>drop view if exists v_student_basic;

-- 一次删除多个视图
drop view if exists v_student_score, v_student_total_points;
</code></pre>
<p><code>IF EXISTS</code> 可避免视图不存在时直接报错。
删除视图不会删除 <code>student</code>、<code>score</code> 等基表数据；但如果其他视图依赖被删除的视图，
依赖者可能变得不可用。<code>DROP VIEW</code> 也是会隐式提交的 DDL。</p>
<hr />
<h2>9. 视图处理原理</h2>
<p>MySQL 处理视图主要有三种算法：</p>
<table>
<thead>
<tr>
<th>算法</th>
<th>基本原理</th>
<th>主要特点</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MERGE</code></td>
<td>把外层查询与视图定义合并成一个查询</td>
<td>通常更容易继续优化，满足其他条件时可更新</td>
</tr>
<tr>
<td><code>TEMPTABLE</code></td>
<td>先把视图结果放入本次语句使用的内部临时表，再查询临时结果</td>
<td>该视图不可更新；临时结果不是永久保存的物化视图</td>
</tr>
<tr>
<td><code>UNDEFINED</code></td>
<td>由 MySQL 选择，可能优先尝试 <code>MERGE</code></td>
<td>默认思路，通常不必手动指定</td>
</tr>
</tbody>
</table>
<p>例如：</p>
<pre><code>create algorithm = merge view v_adult_student as
select id, name, age
from student
where age &gt;= 18;
</code></pre>
<p>执行：</p>
<pre><code>select *
from v_adult_student
where id &lt; 100;
</code></pre>
<p>采用 <code>MERGE</code> 时，可以近似理解为 MySQL 合并两个条件后查询基表：</p>
<pre><code>select id, name, age
from student
where age &gt;= 18 and id &lt; 100;
</code></pre>
<p>视图不会自动拥有索引，普通视图也不能像表一样单独创建索引。查询性能主要取决于展开后的 SQL、基表索引、数据量和优化器选择；应使用 <code>EXPLAIN</code> 分析最终查询，不能把“创建视图”等同于“查询加速”。</p>
<hr />
<h2>10. 视图的安全上下文</h2>
<p>完整语法中的 <code>SQL SECURITY</code> 决定执行视图时按照谁的权限检查底层对象：</p>
<ul>
<li><code>SQL SECURITY DEFINER</code>：按视图定义者的权限执行，是默认值；</li>
<li><code>SQL SECURITY INVOKER</code>：按调用视图的用户权限执行。</li>
</ul>
<pre><code>create sql security invoker view v_student_public as
select id, name, age
from student;
</code></pre>
<p>要使用视图保护数据，应让普通用户只有所需视图的权限，而没有敏感基表的直接权限。仅仅不把敏感列写进视图，并不能阻止一个本来就能查询基表的用户。</p>
<hr />
<h2>参考</h2>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/create-view.html">MySQL 8.0：CREATE VIEW</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/view-algorithms.html">MySQL 8.0：视图处理算法</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/view-updatability.html">MySQL 8.0：可更新与可插入视图</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/view-check-option.html">MySQL 8.0：WITH CHECK OPTION</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/view-metadata.html">MySQL 8.0：视图元数据</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/drop-view.html">MySQL 8.0：DROP VIEW</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 事务：ACID、隔离级别与 MVCC</title>
    <link href="https://blog.syrena.cn/posts/database-transaction/database-transaction/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-transaction/database-transaction/</id>
    <published>2026-08-07T00:00:00.000Z</published>
    <updated>2026-08-07T00:00:00.000Z</updated>
    <summary>系统学习MySQL事务、ACID、隔离级别、MVCC机制.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0，主要讨论 InnoDB</p>
<p>事务不是把 SQL 写在一起就结束，而是为一组相关操作划定“共同成功或共同失败”的边界。</p>
</blockquote>
<hr />
<h2>1. 事务</h2>
<p>事务（Transaction）是数据库执行工作的逻辑单位，可以包含一条或多条 SQL。事务结束只有两种主要结果：</p>
<ul>
<li><code>COMMIT</code>：确认全部修改，使结果持久化；</li>
<li><code>ROLLBACK</code>：撤销本事务尚未提交的修改；</li>
</ul>
<p>例如“张三向李四转账 100 元”至少包含扣款和入账两次更新。如果扣款成功后程序异常，而入账没有执行，总金额就会错误；所以两次更新必须属于同一事务</p>
<p>事务能力由存储引擎提供；InnoDB 支持事务，MyISAM 不支持，先检查表的引擎：</p>
<pre><code>show engines;
show table status like 'bank_account';
show create table bank_account;
</code></pre>
<p>一次事务最好只操作支持事务的表。若混入非事务表，<code>ROLLBACK</code> 不能撤销其修改，会破坏“共同成功或失败”。</p>
<hr />
<h2>2. ACID 特性</h2>
<table>
<thead>
<tr>
<th>特性</th>
<th>含义</th>
<th>转账中的表现</th>
<th>InnoDB 的主要实现</th>
</tr>
</thead>
<tbody>
<tr>
<td>Atomicity 原子性</td>
<td>操作不可只完成一部分</td>
<td>扣款和入账一起成功或撤销</td>
<td>undo log、回滚机制</td>
</tr>
<tr>
<td>Consistency 一致性</td>
<td>事务前后都满足数据与业务规则</td>
<td>总额不变、余额合法</td>
<td>约束、应用规则，以及其他三项共同保证</td>
</tr>
<tr>
<td>Isolation 隔离性</td>
<td>并发事务尽量像互不干扰地执行</td>
<td>转账中间状态不被错误使用</td>
<td>MVCC、锁、隔离级别</td>
</tr>
<tr>
<td>Durability 持久性</td>
<td>成功提交的结果能在故障后恢复</td>
<td>返回成功后转账不会因宕机消失</td>
<td>redo log、刷盘与崩溃恢复</td>
</tr>
</tbody>
</table>
<p>一致性不是“开启事务后数据库自动理解全部业务”。数据库可保证主键、外键、<code>CHECK</code> 等规则，但“转账总额不变”“库存不能超卖”等业务不变量仍要由正确 SQL、锁定方式和应用判断共同保证。</p>
<hr />
<h2>3. 基本语法与事务边界</h2>
<pre><code>show engines; 	    -- 查看支持事务的存储引擎

start transaction;  -- 从这里开始，后面的修改先不要自动提交
-- 或 begin;

-- 执行 select / insert / update / delete 后
commit;             -- 提交并结束事务/永久保存

rollback;			-- 回滚当前事务/撤销修改
</code></pre>
<p><code>START TRANSACTION</code> 与 <code>BEGIN</code> 在普通 SQL 中都能开启事务；前者还能使用：</p>
<pre><code>start transaction read only;
start transaction read write;
start transaction with consistent snapshot;
</code></pre>
<p><code>READ ONLY</code> 适合明确的只读事务，InnoDB 可据此优化。<code>WITH CONSISTENT SNAPSHOT</code> 在 <code>REPEATABLE READ</code> 下立即建立一致性快照。</p>
<p>MySQL 不支持普通事务嵌套。在未结束的事务中再次执行 <code>START TRANSACTION</code>，会先隐式提交原事务，不是创建子事务。存储过程里的 <code>BEGIN ... END</code> 表示程序块；需要开启事务时应明确写 <code>START TRANSACTION</code>。</p>
<hr />
<h2>4. 转账事务实操</h2>
<h3>4.1 开启事务，执行修改后回滚</h3>
<p>开启事务</p>
<pre><code># 开启事务
mysql&gt; START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

# 在修改之前查看表中的数据
mysql&gt; SELECT * FROM bank_account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | 张三 | 1000.00 |
|  2 | 李四 | 1000.00 |
+----+------+---------+
2 rows in set (0.00 sec)
</code></pre>
<p>执行修改</p>
<pre><code># 张三余额减少 100
mysql&gt; UPDATE bank_account
       SET balance = balance - 100
       WHERE name = '张三';
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

# 李四余额增加 100
mysql&gt; UPDATE bank_account
       SET balance = balance + 100
       WHERE name = '李四';
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

# 修改之后、回滚之前查看表中的数据
mysql&gt; SELECT * FROM bank_account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | 张三 |  900.00 |
|  2 | 李四 | 1100.00 |
+----+------+---------+
2 rows in set (0.00 sec)
</code></pre>
<p>回滚事务</p>
<pre><code># 回滚事务
mysql&gt; ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

# 再次查询，发现修改没有生效
mysql&gt; SELECT * FROM bank_account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | 张三 | 1000.00 |
|  2 | 李四 | 1000.00 |
+----+------+---------+
2 rows in set (0.00 sec)
</code></pre>
<h3>4.2 开启事务，执行修改后提交</h3>
<p>开启事务</p>
<pre><code># 开启事务
mysql&gt; BEGIN;
Query OK, 0 rows affected (0.00 sec)

# 在修改之前查看表中的数据
mysql&gt; SELECT * FROM bank_account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | 张三 | 1000.00 |
|  2 | 李四 | 1000.00 |
+----+------+---------+
2 rows in set (0.00 sec)

</code></pre>
<p>执行修改</p>
<pre><code># 张三余额减少 100
mysql&gt; UPDATE bank_account
       SET balance = balance - 100
       WHERE name = '张三';
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

# 李四余额增加 100
mysql&gt; UPDATE bank_account
       SET balance = balance + 100
       WHERE name = '李四';
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

# 修改之后、提交之前查看表中的数据
mysql&gt; SELECT * FROM bank_account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | 张三 |  900.00 |
|  2 | 李四 | 1100.00 |
+----+------+---------+
2 rows in set (0.00 sec)
</code></pre>
<p>提交事务</p>
<pre><code># 提交事务
mysql&gt; COMMIT;
Query OK, 0 rows affected (0.01 sec)

# 再次查询，数据已经被修改，说明数据已经持久化到磁盘
mysql&gt; SELECT * FROM bank_account;
+----+------+---------+
| id | name | balance |
+----+------+---------+
|  1 | 张三 |  900.00 |
|  2 | 李四 | 1100.00 |
+----+------+---------+
2 rows in set (0.00 sec)
</code></pre>
<hr />
<h2>5. 自动提交与隐式提交</h2>
<p>MySQL 新连接默认开启自动提交。未显式开启事务时，每条成功语句各自形成一个事务，执行后立即提交：</p>
<pre><code>select @@session.autocommit;	-- 查看当前设置
set autocommit = 0;  -- 当前会话关闭自动提交
set autocommit = 1;  -- 恢复自动提交
</code></pre>
<p>两种常用方式：</p>
<pre><code>-- 方式一：保留默认自动提交，只临时开启一次事务
start transaction;
update bank_account set balance = balance + 10 where id = 1;
commit;

-- 方式二：持续关闭当前连接的自动提交
set autocommit = 0;
update bank_account set balance = balance - 10 where id = 1;
commit;              -- 之后仍需继续显式提交或回滚
</code></pre>
<p>当 <code>autocommit=0</code> 时，会话结束而最后一个事务未提交，MySQL 会回滚它；从 <code>0</code> 改成 <code>1</code> 会隐式提交当前事务。</p>
<p>以下语句通常会隐式提交当前事务，不能指望随后 <code>ROLLBACK</code> 撤销：</p>
<ul>
<li>DDL：<code>CREATE</code>、<code>ALTER</code>、<code>DROP</code>、<code>TRUNCATE</code>、<code>RENAME</code>；</li>
<li>部分账户、管理和锁表语句；</li>
<li>再次执行 <code>START TRANSACTION</code>，或执行使值从 0 变为 1 的 <code>SET autocommit=1</code>。</li>
</ul>
<p>注意：</p>
<blockquote>
<p>[!CAUTION]</p>
<ul>
<li>只要使用 <code>start transaction</code> 或 <code>begin</code> 开启事务，必须要通过 <code>commit</code> 提交才会持久化，与是否设置 <code>set autocommit</code> 无关；</li>
<li>手动提交模式下，不用显式开启事务，执行修改操作后，提交或回滚事务时直接使用<code>commit</code> 或 <code>rollback</code>；</li>
<li>已提交的事务不能回滚；</li>
</ul>
</blockquote>
<hr />
<h2>6. 保存点</h2>
<p>涉及语法：</p>
<pre><code>savepoint 保存点名称;
</code></pre>
<p>回滚到指定保存点：</p>
<pre><code>rollback to 保存点名称;
</code></pre>
<p>例如：</p>
<p>保存点用于只撤销事务后半段，不等于提交前半段：</p>
<pre><code>start transaction;
savepoint before_transfer;

update bank_account set balance = balance - 100 where id = 1;
update bank_account set balance = balance + 100 where id = 2;
rollback to savepoint before_transfer;  -- 撤销上面两次更新

-- 改用 50 元重新执行
update bank_account set balance = balance - 50 where id = 1;
update bank_account set balance = balance + 50 where id = 2;

release savepoint before_transfer;      -- 删除保存点，不提交事务
commit;
</code></pre>
<p><code>ROLLBACK TO SAVEPOINT</code> 不结束事务；目标保存点之后创建的保存点会被删除。<code>COMMIT</code> 或不带保存点的 <code>ROLLBACK</code> <strong>才会结束事务并清除全部保存点</strong>。InnoDB 回滚到保存点时不保证释放之后取得的全部内存行锁，因此保存点不能代替“缩短事务”。</p>
<hr />
<h2>7. 事务的隔离性和四种隔离级别</h2>
<h3>7.1 隔离性</h3>
<p>MySQL服务被多个客户端同时访问，每个客户端执行的DML语句以事务为基本单位，那么不同的客户端在对同一张表中的<strong>同一条数据进行修改时就可能出现相互影响</strong>的情况，为保证不同事务直接执行时不受影响，因此事务之间就需要相互隔离</p>
<h3>7.2 隔离级别及设置语法</h3>
<table>
<thead>
<tr>
<th>隔离级别</th>
<th>脏读</th>
<th>不可重复读</th>
<th>幻读（SQL 标准）</th>
<th>InnoDB 主要行为</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>READ UNCOMMITTED</code> 读未提交</td>
<td>可能</td>
<td>可能</td>
<td>可能</td>
<td>普通读可能看到未提交版本，业务中很少使用</td>
</tr>
<tr>
<td><code>READ COMMITTED</code> 读已提交</td>
<td>避免</td>
<td>可能</td>
<td>可能</td>
<td>每条一致性读建立新快照；通常不使用间隙锁</td>
</tr>
<tr>
<td><code>REPEATABLE READ</code> 可重复读</td>
<td>避免</td>
<td>避免</td>
<td>标准允许</td>
<td>InnoDB 默认；普通读复用快照，范围锁定读用 Next-Key Lock 防典型幻读</td>
</tr>
<tr>
<td><code>SERIALIZABLE</code> 串行化</td>
<td>避免</td>
<td>避免</td>
<td>避免</td>
<td>显式事务中的普通读会按共享锁读处理，冲突等待更多</td>
</tr>
</tbody>
</table>
<p>从上往下：并发性能越低，隔离度（安全性）越高</p>
<p>隔离越强不代表所有事务真的在全库中逐个执行。即使是 <code>SERIALIZABLE</code>，互不冲突的事务仍可并发；它主要通过更严格的锁让冲突操作等待，因此吞吐量通常更低。</p>
<p>涉及语法：</p>
<ol>
<li>
<p>查看全局隔离级别</p>
<pre><code>select @@global.transaction_isolation;
</code></pre>
</li>
<li>
<p>查看当前会话隔离级别</p>
<pre><code>select @@session.transaction_isolation;
</code></pre>
</li>
</ol>
<p><strong>以下是设置SESSION隔离级别</strong></p>
<p>设置事务的隔离级别和访问模式，可以使用以下语法：</p>
<pre><code># 通过 GLOBAL | SESSION 分别指定不同作用域
SET [GLOBAL | SESSION] TRANSACTION transaction_characteristic;

transaction_characteristic: {
      ISOLATION LEVEL level
    | access_mode
}

# 隔离级别
level: {
      REPEATABLE READ     # 可重复读
    | READ COMMITTED      # 读已提交
    | READ UNCOMMITTED    # 读未提交
    | SERIALIZABLE        # 串行化
}

# 访问模式
access_mode: {
      READ WRITE          # 事务可以读取和修改数据
    | READ ONLY           # 事务只能读取数据，不能修改普通表中的数据
}

# 示例

# 设置全局事务隔离级别为串行化
# 对之后新建的连接生效，不影响当前及已有连接
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;

# 设置当前会话的事务隔离级别为串行化
# 对当前会话后续的所有事务生效，不影响正在执行的事务
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

# 不指定作用域时，只对当前会话的下一个事务生效
# 随后的事务恢复为之前的会话隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
</code></pre>
<pre><code># 方式一：直接设置系统变量
SET GLOBAL transaction_isolation = 'SERIALIZABLE';

# 隔离级别中原本使用空格的位置，需要改成连字符
SET SESSION transaction_isolation = 'REPEATABLE-READ';


# 方式二：使用 @@GLOBAL 和 @@SESSION 设置系统变量
SET @@GLOBAL.transaction_isolation = 'SERIALIZABLE';

# 隔离级别中原本使用空格的位置，需要改成连字符
SET @@SESSION.transaction_isolation = 'REPEATABLE-READ';
</code></pre>
<p>使用系统变量赋值时值中写连字符，如 <code>SET SESSION transaction_isolation='READ-COMMITTED'</code>；使用 <code>SET TRANSACTION ... LEVEL</code> 语法时写空格。改变隔离级别前应确认作用域，做双会话实验时直接设置 <code>SESSION</code> 最清楚。</p>
<h3>7.3 脏读示例</h3>
<blockquote>
<p><strong>脏读</strong>：一个事务读取到了另一个事务尚未提交的数据。</p>
</blockquote>
<p>客户端 A 设置 READ UNCOMMITTED</p>
<p>客户端 B 设置 READ UNCOMMITTED</p>
<p><strong>客户端 A 开启事务并修改，但不提交</strong></p>
<pre><code>start transaction;

update bank_account
set balance = 100000
where name = '王五';

select *
from bank_account
where name = '王五';
</code></pre>
<p>客户端 A 自己看到：王五 100000.00</p>
<p><strong>客户端 B 读取数据</strong></p>
<pre><code>start transaction;

select *
from bank_account
where name = '王五';
</code></pre>
<p>在 <code>READ UNCOMMITTED</code> 下，按照资料的实验逻辑，客户端 B 可以看到事务A尚未提交的修改：王五 100000.00</p>
<p>此时发生了<strong>脏读</strong></p>
<pre><code>客户端A                         客户端B
事务A                           事务B

START TRANSACTION
     │
UPDATE 王五=100000
     │
没有COMMIT
     │
     ├──────────────────────→ SELECT
     │                         读到100000
     │
ROLLBACK
     │
王五恢复2000
                               SELECT
                               读到2000
</code></pre>
<h3>7.4 不可重复读示例</h3>
<blockquote>
<p><strong>不可重复读</strong>：同一个事务中，使用相同条件读取同一行数据，两次得到的字段值不同。</p>
</blockquote>
<pre><code>客户端A                             客户端B
事务A                               事务B

START TRANSACTION
    │
SELECT 王五
    │
    └── 2000

                                START TRANSACTION
                                       │
                                UPDATE 王五=1000
                                       │
                                   尚未COMMIT

SELECT 王五
    │
    └── 2000
        ↑
        看不到B未提交的数据

                                   COMMIT
                                     │
                                     ↓
                                1000正式提交

SELECT 王五
    │
    └── 1000

事务A中：
第一次 = 2000
第二次 = 1000

→ 不可重复读
事务 A 中的两条查询完全相同，但第一次得到 2000，第二次得到 1000，因此称为“不可重复读”。
</code></pre>
<p>因为 InnoDB 在 <code>READ COMMITTED</code> 下，每次普通一致性查询都会建立一个新的数据快照，所以第二次查询可以看到其他事务新提交的修改。</p>
<p>在 <code>REPEATABLE READ</code> 下，同一个事务中的普通 <code>SELECT</code> 通常使用第一次查询建立的快照，所以能够保持重复读取结果一致。</p>
<h3>7.5 幻读示例</h3>
<blockquote>
<p><strong>幻读</strong>：同一个事务中，使用相同范围条件查询两次，第二次查询出现或消失了一些<strong>符合条件的记录</strong></p>
</blockquote>
<p>假设第一次查询余额不低于 1000 元的账户：</p>
<pre><code>-- 事务 A
start transaction;

select *
from bank_account
where balance &gt;= 1000;
</code></pre>
<p>第一次结果有两行：</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>balance</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>张三</td>
<td>1000</td>
</tr>
<tr>
<td>2</td>
<td>李四</td>
<td>1000</td>
</tr>
</tbody>
</table>
<p>然后事务 B 插入一条符合条件的数据并提交：</p>
<pre><code>-- 事务 B
start transaction;

insert into bank_account (name, balance)
values ('王五', 2000);

commit;
</code></pre>
<p>事务 A 再次执行相同查询：</p>
<pre><code>select *
from bank_account
where balance &gt;= 1000;
</code></pre>
<p>如果第二次结果变成三行：</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>balance</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>张三</td>
<td>1000</td>
</tr>
<tr>
<td>2</td>
<td>李四</td>
<td>1000</td>
</tr>
<tr>
<td>3</td>
<td>王五</td>
<td>2000</td>
</tr>
</tbody>
</table>
<p>事务 A 会感觉像凭空出现了一条记录，因此称为“幻读”。</p>
<p><strong>注意：</strong></p>
<p>不可重复读是<strong>指同一行的值变</strong>了，而幻读是<strong>结果集中的行变</strong>了。</p>
<p><strong>不可重复读</strong>通常是由另一个事务执行 <code>update</code> 并提交引起；</p>
<p><strong>幻读</strong>通常是由另一个事务执行 <code>insert</code> 或 <code>delete</code> 并提交引起。</p>
<p><strong>但 MySQL InnoDB 比较特殊</strong></p>
<p>在InnoDB的 REPEATABLE READ 下，典型的幻读通常已经被解决。涉及到MVCC，Next-Key Lock。</p>
<h3>7.6 串行化 SERIALIZABLE 解释</h3>
<p>尽量让事务表现得像<strong>一个一个顺序执行</strong>。</p>
<pre><code>事务A
  ↓
执行完成

事务B
  ↓
执行完成

事务C
  ↓
执行完成
</code></pre>
<hr />
<h2>8. MVCC 与一致性读原理</h2>
<p>MVCC（多版本并发控制）让读操作在许多情况下不必阻塞写操作。InnoDB 聚集索引记录内部包含：</p>
<ul>
<li><code>DB_TRX_ID</code>：最后一次插入或更新该行的事务 ID；</li>
<li><code>DB_ROLL_PTR</code>：指向 undo log 中的旧版本；</li>
<li>没有合适聚集键时还会使用隐藏的 <code>DB_ROW_ID</code>。</li>
</ul>
<pre><code>flowchart LR
    Q["一致性读与 Read View"] --&gt; C["当前版本：trx 120"]
    C --&gt; U1["undo 旧版本：trx 105"]
    U1 --&gt; U2["undo 更旧版本：trx 88"]
    Q -.-&gt; U1
</code></pre>
<p>查询读取当前记录后，用 Read View 判断创建该版本的事务对自己是否可见；不可见就沿 <code>DB_ROLL_PTR</code> 在 undo 版本链中寻找第一个可见版本。Read View 可近似理解为“生成快照时哪些事务已提交、哪些仍活跃”的可见性规则，不是复制出一整份数据库。</p>
<ul>
<li><code>READ COMMITTED</code>：每条普通 <code>SELECT</code> 建立新 Read View，所以同一事务后一次查询可能看到新提交；</li>
<li><code>REPEATABLE READ</code>：通常由第一次一致性读建立 Read View，事务内后续普通读复用它；</li>
<li>当前事务自己的修改始终可见。</li>
</ul>
<p>长事务会让旧版本长期不能清理，使 undo 膨胀并增加版本链读取成本，所以不要无故让事务长时间保持开启。</p>
<hr />
<h2>参考</h2>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/commit.html">MySQL 8.0：事务的开始、提交与回滚</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html">MySQL 8.0：事务隔离级别</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html">MySQL 8.0：一致性非锁定读</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html">MySQL 8.0：InnoDB 锁</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html">MySQL 8.0：MVCC</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html">MySQL 8.0：隐式提交</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 索引原理：B+ 树、数据页与查询优化</title>
    <link href="https://blog.syrena.cn/posts/database-index/database-index/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-index/database-index/</id>
    <published>2026-08-04T00:00:00.000Z</published>
    <updated>2026-08-04T00:00:00.000Z</updated>
    <summary>系统学习MySQL索引原理、结构、创建方法与查询优化.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0，存储引擎以 InnoDB 为主。</p>
<p>索引的最终目标不是“数量多”，而是让常用查询以更少的页面访问得到更少的候选行。</p>
</blockquote>
<hr />
<h2>1. 索引</h2>
<p>索引是存储引擎维护的、有序的数据结构。它保存索引键及定位记录所需的信息，使 MySQL 不必从第一行扫描到最后一行</p>
<p>索引类似书的目录，但数据库索引还会随着数据变化而维护：</p>
<ul>
<li><code>select</code> 可能因索引减少扫描、排序和回表；</li>
<li><code>insert</code>、<code>delete</code>、更新索引列时，需要同步修改索引；</li>
<li>索引占用磁盘与缓冲池空间；</li>
<li>优化器按成本选择执行计划，有索引不等于一定使用；</li>
</ul>
<p>因此，索引是“以空间和写入成本换取读取效率”，适合高频查询，而不是给所有列机械建索引</p>
<hr />
<h2>2. InnoDB 主要使用 B+ 树</h2>
<blockquote>
<p>[!NOTE]</p>
<p><strong>InnoDB</strong> 是 <strong>MySQL</strong> 的一种存储引擎，负责真正管理表的数据、索引、事务等底层操作</p>
</blockquote>
<h3>2.1 Hash 为什么不是默认选择</h3>
<p>Hash 根据哈希值定位桶，平均等值查找很快，但键值没有顺序，难以支持 <code>&lt;</code>、<code>between</code>、前缀排序和连续范围扫描。InnoDB 的普通索引只支持 B-tree；<code>MEMORY</code> 引擎才允许显式选择 Hash。InnoDB 内部的自适应哈希索引是一种自动优化机制，不能等同于用户创建的索引。</p>
<h3>2.2 二叉树为什么不适合磁盘索引</h3>
<p><img src="./Index0.png" alt="image-20260802185245361" /></p>
<p>普通二叉搜索树可能退化成链表；</p>
<p>AVL、红黑树虽能控制高度，但每个节点只有两个分支；</p>
<p>面对海量数据时树仍然较高；</p>
<p>索引节点通常按“页”存储，向下一层可能意味着访问另一个页面，所以数据库更希望一层容纳更多分支，减少树高和页面访问次数</p>
<h3>2.3 B+ 树</h3>
<h4>2.3.1 B+树的优势</h4>
<p>B+ 树是多路平衡查找树，核心特点如下：</p>
<p><img src="./Index1.png" alt="image-20260802183816771" /></p>
<ol>
<li>非叶子节点主要保存分隔键和子页面指针，同一页可容纳较多分支，树通常很矮；</li>
<li>真正的索引记录集中在叶子层，从根到任意叶子的路径长度相同；</li>
<li>叶子页按键值顺序组织，并通过链表相连，找到范围起点后可顺序扫描；</li>
<li>插入、删除会触发页分裂、合并或重平衡，仍能保持有序和平衡；</li>
</ol>
<pre><code>flowchart TD
    R["根页：分隔键"]
    R --&gt; I1["内部页 A"]
    R --&gt; I2["内部页 B"]
    I1 --&gt; L1["叶子页：1～20"]
    I1 --&gt; L2["叶子页：21～40"]
    I2 --&gt; L3["叶子页：41～60"]
    L1 &lt;--&gt; L2
    L2 &lt;--&gt; L3
</code></pre>
<p>等值查询沿根页逐层缩小范围；范围查询先定位第一个叶子记录，再沿叶子页有序扫描。MySQL 文档常统称其为 B-tree，讨论 InnoDB 实现原理时通常称 B+ 树。</p>
<h4>2.3.2 B树与B+树的区别</h4>
<table>
<thead>
<tr>
<th>区别</th>
<th>B树</th>
<th>B+树</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据存在哪里</td>
<td>所有节点</td>
<td>只有叶子节点</td>
</tr>
<tr>
<td>非叶子节点</td>
<td>保存数据</td>
<td>只保存索引</td>
</tr>
<tr>
<td>叶子节点</td>
<td>没有特殊关系</td>
<td>通过链表连接</td>
</tr>
<tr>
<td>范围查询</td>
<td>一般</td>
<td>非常方便</td>
</tr>
<tr>
<td>数据库使用</td>
<td>较少</td>
<td>大量使用</td>
</tr>
</tbody>
</table>
<hr />
<h2>3. MySQL 中的页</h2>
<h3>3.1 InnoDB 的数据页</h3>
<p>页是 InnoDB 管理索引和数据的基本单位，索引页默认大小为 16KB：</p>
<pre><code>show variables like 'innodb_page_size';
</code></pre>
<p>每一个页中即使没有数据也会使用<code>16KB</code>的存储空间；</p>
<p>一次查询需要的页若已在 Buffer Pool 中，可直接从内存读取；不在时才需要从磁盘加载。按页读取利用了局部性：刚访问的数据及其附近数据，近期再次使用的概率较高。</p>
<p>一个索引页可概括为：</p>
<table>
<thead>
<tr>
<th>区域</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>File Header / Trailer</td>
<td>页号、校验及前后页等信息</td>
</tr>
<tr>
<td>Page Header</td>
<td>记录数、目录槽数、空闲空间等页内状态</td>
</tr>
<tr>
<td>Infimum / Supremum</td>
<td>表示页内最小、最大边界的系统记录</td>
</tr>
<tr>
<td>User Records</td>
<td>按索引键组织的用户记录</td>
</tr>
<tr>
<td>Page Directory</td>
<td>保存分组末端记录的槽，辅助页内二分定位</td>
</tr>
</tbody>
</table>
<p>数据页的基本结构如下图：</p>
<p><img src="./Index2.png" alt="image-20260803214934039" /></p>
<p>页内记录通过链表维持逻辑顺序；查询时先对 Page Directory 的槽做二分定位，再在很小的分组内查找。B+ 树解决“定位哪一页”，页目录解决“在这一页的哪里”。叶子页之间的双向链表则服务于跨页范围扫描。</p>
<p>类似“<strong>三层 B+ 树可存两千多万行</strong>”只是基于固定页大小、索引项和行大小的估算。真实容量会受主键长度、行格式、变长列、页填充率和分裂影响；实际查询也不等于固定发生三次磁盘 I/O，因为根页和热点页通常已被缓存。</p>
<h3>3.2 页文件头和页文件尾</h3>
<p>页文件头与页文件尾中包含的信息如下图：</p>
<p><img src="./Index3.png" alt="image-20260803215355955" /></p>
<p>上图中我们只关注：上一页页号和下一页页号，就类似链表中的头节点、尾节点链接形成一个<strong>双向链表</strong></p>
<h3>3.3 页主体</h3>
<p>页主体是保存真实数据的主要区域，在每当创建一个新页时，都会自动分配两个行，分别是页内最小行<code>Infimun</code>、页内最大行<code>Supremun</code>，这两行实际不存储任何真实信息，而是做为数据行链表的头和尾。</p>
<p>此外，每一个数据行都有一个记录下一行的地址偏移量的区域<code>next_record</code>将页内所有数据行组成了一个单向链表，其结构图如下：</p>
<p><img src="./Index4.png" alt="image-20260804004331641" /></p>
<p>当有一个新页插入数据时，将<code>Infimun</code>连接第一个数据行,最后一行真实数据行连接<code>Supremun</code>，该单向链表就如下图结构所示：</p>
<p><img src="./Index5.png" alt="image-20260804004834696" /></p>
<h3>3.4 页目录</h3>
<p>为了避免沿着链表顺序逐个比对查找每个页数百行数据，InnoDB通过二分查找法来解决查找效率问题；</p>
<p>所利用的方式就是在每一页中加入一个叫页目录<code>Page Directory</code>的结构，将页内包括头行、尾行在内的所有行进行分组，约定头行单独为一组，其他组<strong>最多8条数据</strong>，同时把每个组最后一行在页中的地址<strong>按主键从小到大</strong>的顺序记录在页目录中；</p>
<p>页目录中的每一个位置称为一个<strong>槽</strong>，每个槽都对应了一个分组，一旦分组中的数据行超过分组的上限8个时，就会分裂出一个新的分组；</p>
<p>所以后续在查询某行时，通过二分查找，先找到对应的槽，随后在槽内最多8个数据行中进行遍历即可；</p>
<p><img src="./Index6.png" alt="image-20260804154049947" /></p>
<h3>3.5 数据页头</h3>
<p>数据页头记录了当前页保存数据相关的信息，如下图：</p>
<p><img src="./Index7.png" alt="image-20260804154333343" /></p>
<h2>4. 聚集索引、二级索引与回表</h2>
<h3>4.1 聚集索引</h3>
<p>每张 InnoDB 表都有一个聚集索引，其叶子记录保存完整行数据，数据按聚集键组织：</p>
<ol>
<li>有 <code>primary key</code>：使用主键作为<strong>聚集索引</strong>；</li>
<li>无主键：选择第一个所有列都为 <code>not null</code> 的 <code>unique</code> <strong>唯一索引</strong>作为聚集索引；</li>
<li>两者都没有：创建名为 <code>GEN_CLUST_INDEX</code> 的<strong>隐藏聚集索引</strong>，使用 6 字节行 ID，也就是偷偷生成<code>DB_ROW_ID</code>，按照这个 <code>GEN_CLUST_INDEX</code> 隐藏ID建立聚集索引；</li>
</ol>
<p>所以“主键索引就是聚集索引”只适用于通常的 InnoDB 主键场景，并非概念上的绝对同义关系。主键应尽量短、稳定；因为主键值还会存入每个二级索引。递增主键通常有利于顺序插入，但仍应先满足业务身份和分布式设计要求</p>
<h3>4.2 二级索引与回表</h3>
<p>聚集索引之外的索引称为<strong>二级索引</strong>。其叶子记录主要保存“二级索引列 + 对应主键值”。例如往篇中提及的在 <code>student_design2(name)</code> 上建索引后，按姓名查询的过程是：</p>
<ol>
<li>搜索姓名索引，得到匹配记录的主键 <code>id</code>；</li>
<li>再用 <code>id</code> 搜索聚集索引，取得完整学生行。</li>
</ol>
<p>第二步叫<strong>回表</strong>。如果查询<strong>所需列已全部包含在同一个二级索引中</strong>，可直接返回，这叫<strong>覆盖索引</strong>。例如索引 <code>(class_id, name)</code> 可覆盖：</p>
<pre><code>select class_id, name
from student_design2
where class_id = 1;
</code></pre>
<p>覆盖索引减少回表，但不要为了覆盖 <code>select *</code> 而建立过宽索引；宽索引会降低单页容量并增加写入成本。</p>
<hr />
<h2>5. 索引的分类</h2>
<table>
<thead>
<tr>
<th>分类角度</th>
<th>类型</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>业务与约束</td>
<td>主键、唯一、普通</td>
<td>主键标识行；唯一索引限制重复；普通索引只加速访问</td>
</tr>
<tr>
<td>索引列数</td>
<td>单列、复合</td>
<td>一个索引由一列或多列按顺序组成</td>
</tr>
<tr>
<td>InnoDB 存储</td>
<td>聚集、二级</td>
<td>叶子保存完整行，或保存索引键与主键</td>
</tr>
<tr>
<td>专用用途</td>
<td>全文、空间</td>
<td>全文检索使用倒排结构；空间索引使用 R-tree</td>
</tr>
</tbody>
</table>
<p>约束负责数据规则，索引负责访问路径，两者不是同一种对象。<code>primary key</code>、<code>unique</code> 会用唯一索引实现；外键要求相关列存在可用索引，从表缺少时 MySQL 会自动创建，但这不代表外键就是索引。</p>
<hr />
<h2>6. 索引操作</h2>
<h3>6.1 手动创建索引</h3>
<h4>6.1.1 主键索引</h4>
<p>主键列的值必须唯一且非空，一张表只能定义一个主键，但主键可以由多列组成。</p>
<h5>方式一：建表时使用列级语法</h5>
<p>适合单列主键，写法最简洁。</p>
<pre><code>create table t_user_pk1 (
    id bigint unsigned primary key auto_increment,
    username varchar(30) not null
);
</code></pre>
<h5>方式二：建表时使用表级语法</h5>
<p>主键定义与字段定义分开，结构更清晰，并且可以定义复合主键。</p>
<pre><code>create table t_user_pk2 (
    id bigint unsigned auto_increment,
    username varchar(30) not null,
    primary key (id)
);
</code></pre>
<h5>方式三：给已有表添加主键</h5>
<p>先添加主键，再设置自增，是因为 <code>auto_increment</code> 列必须受到索引支持。</p>
<pre><code>create table t_user_pk3 (
    id bigint unsigned not null,
    username varchar(30) not null
);

alter table t_user_pk3
    add primary key (id);

alter table t_user_pk3
    modify id bigint unsigned not null auto_increment;
</code></pre>
<p>添加前必须检查已有的 <code>id</code> 是否包含 <code>NULL</code> 或重复值，否则主键创建会失败。<code>CREATE INDEX</code> 不能创建主键，已有表必须使用 <code>ALTER TABLE ... ADD PRIMARY KEY</code>；</p>
<h4>6.1.2 唯一索引</h4>
<p>唯一索引用于电话号码、学号、邮箱等不可重复的业务字段。若字段允许 <code>NULL</code>，MySQL 唯一索引通常允许多个 <code>NULL</code>；要求必须填写且唯一时，应同时定义 <code>NOT NULL</code>。</p>
<h5>方式一：建表时使用列级语法</h5>
<pre><code>create table t_user_uk1 (
    id bigint unsigned primary key auto_increment,
    username varchar(30) not null unique
);
</code></pre>
<h5>方式二：建表时命名唯一约束</h5>
<p>显式命名便于根据业务含义识别和删除，工程中更推荐这种写法。</p>
<pre><code>create table t_user_uk2 (
    id bigint unsigned primary key auto_increment,
    username varchar(30) not null,
    constraint uk_user_username unique (username)
);
</code></pre>
<h5>方式三：给已有表添加唯一约束</h5>
<pre><code>alter table t_user_uk3
    add constraint uk_user_username unique (username);
</code></pre>
<h5>方式四：使用独立的索引语句</h5>
<pre><code>create unique index uk_user_username
    on t_user_uk4 (username);
</code></pre>
<p><code>UNIQUE</code> 强调“不允许重复”的数据规则，<code>UNIQUE INDEX</code> 强调实现该规则的索引结构；在 MySQL 中，两者都会建立唯一索引；</p>
<h4>6.1.3 普通索引</h4>
<p>普通索引只提供查询访问路径，不限制字段值重复。索引名建议使用 <code>idx_表或业务_字段</code>，便于维护。</p>
<h5>方式一：建表时创建</h5>
<pre><code>create table t_user_idx1 (
    id bigint unsigned primary key auto_increment,
    phone varchar(20),
    index idx_user_phone (phone)
);
</code></pre>
<h5>方式二：使用 <code>ALTER TABLE</code> 给已有表添加</h5>
<pre><code>alter table t_user_idx2
    add index idx_user_phone (phone);
</code></pre>
<h5>方式三：使用 <code>CREATE INDEX</code> 独立创建</h5>
<pre><code>create index idx_user_phone
    on t_user_idx3 (phone);
</code></pre>
<p>方式二和方式三最终都给已有表增加索引。<code>ALTER TABLE</code> 适合把多项表结构调整写在一起，<code>CREATE INDEX</code> 的语义则更直观。</p>
<p>MySQL 8.0.13 及以上还支持函数索引，表达式需要额外一层括号：</p>
<pre><code>create index idx_user_birth_year
    on t_user_idx3 ((year(birthday)));
</code></pre>
<p>函数索引保存的是表达式结果，适合不能改写成原列范围查询、且确实频繁使用相同表达式的场景；</p>
<h4>6.2.4 复合索引</h4>
<p>复合索引包含两个或更多索引列，创建语法与单列索引相同，只需按查询需要排列多个字段。具体利用规则见第 7 节“复合索引与最左前缀”。</p>
<h5>方式一：建表时创建</h5>
<pre><code>create table t_user_multi1 (
    id bigint unsigned primary key auto_increment,
    class_id bigint unsigned not null,
    username varchar(30) not null,
    index idx_user_class_name (class_id, username)
);
</code></pre>
<h5>方式二：使用 <code>ALTER TABLE</code> 添加</h5>
<pre><code>alter table t_user_multi2
    add index idx_user_class_name (class_id, username);
</code></pre>
<h5>方式三：使用 <code>CREATE INDEX</code> 创建</h5>
<pre><code>create index idx_user_class_name
    on t_user_multi3 (class_id, username);
</code></pre>
<p>复合索引也可以具有主键或唯一属性：</p>
<pre><code>-- 复合主键：同一学生与同一课程只能形成一条记录
primary key (student_id, course_id)

-- 复合唯一索引：同一班级中学号不能重复
unique index uk_student_class_sno (class_id, sno)
</code></pre>
<h3>6.2 查看索引</h3>
<h4>6.2.1 查看完整索引信息</h4>
<p>下面三种写法等价，实际使用一种即可：</p>
<pre><code>show index from t_user_multi1;
show indexes from t_user_multi1;
show keys from t_user_multi1;
</code></pre>
<p>MySQL 命令行客户端可用 <code>\G</code> 将宽表结果纵向显示：</p>
<pre><code>show keys from t_user_multi1\G
</code></pre>
<p><code>\G</code> 是客户端结束符，不是标准 SQL 语法。在 DBeaver 中通常直接执行带分号的 <code>SHOW INDEX</code>，通过结果网格查看即可。</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Key_name</code></td>
<td>索引名称；主键固定为 <code>PRIMARY</code></td>
</tr>
<tr>
<td><code>Non_unique</code></td>
<td><code>0</code> 表示不允许重复，<code>1</code> 表示允许重复</td>
</tr>
<tr>
<td><code>Seq_in_index</code></td>
<td>当前列在复合索引中的顺序，从 <code>1</code> 开始</td>
</tr>
<tr>
<td><code>Column_name</code></td>
<td>索引列名</td>
</tr>
<tr>
<td><code>Cardinality</code></td>
<td>不同值数量的估算，不是精确统计值</td>
</tr>
<tr>
<td><code>Index_type</code></td>
<td>索引结构，InnoDB 普通索引通常显示 <code>BTREE</code></td>
</tr>
<tr>
<td><code>Visible</code></td>
<td>该索引是否对优化器可见</td>
</tr>
<tr>
<td><code>Expression</code></td>
<td>函数索引使用的表达式，普通列索引通常为 <code>NULL</code></td>
</tr>
</tbody>
</table>
<h4>6.2.2 查看表结构摘要</h4>
<pre><code>desc t_user_multi1;
</code></pre>
<p><code>DESC</code> 的 <code>Key</code> 列只提供摘要：<code>PRI</code> 表示主键，<code>UNI</code> 表示唯一索引，<code>MUL</code> 表示该列可出现重复值的索引。它不能完整展示索引名和复合索引列顺序，因此不能代替 <code>SHOW INDEX</code>。</p>
<h4>6.2.3 查看真实建表定义</h4>
<pre><code>show create table t_user_multi1;
</code></pre>
<p>修改或删除索引前先查看建表语句，可以确认字段完整属性、约束名和索引名，避免删除错对象。</p>
<h4>6.2.4 查看是否使用索引</h4>
<p>使用 <code>explain + select </code> 查询可以知道该查询<strong>是否使用了索引</strong>以及<strong>使用了哪个索引</strong></p>
<p>例如：</p>
<p><img src="./Index8.png" alt="" /></p>
<ol>
<li>
<p><code>select_type</code>：查询类型，常见有<code>simple</code>、<code>primary</code>、<code>subquery</code>、<code>derived</code>、<code>union</code>；</p>
</li>
<li>
<p><code>type</code>：MySQL 访问这张表时采用什么方式。排序有以下</p>
<p>【<strong>好</strong> &lt;- system、const、eq_ref、ref、fulltext、ref_or_null、index_merge、unique_subquery、index_subquery、range、index、ALL -&gt; <strong>差</strong>】；</p>
<p>All：全表扫描</p>
<p><strong>index</strong>：扫描全部索引树</p>
<p>range：扫描部分索引，索引范围扫描，对索引的扫描开始于某一点，返回匹配值域的行，常见于between、&lt;、&gt;等的查询</p>
<p><strong>ref</strong>：使用非唯一索引或非唯一索引前缀进行的查找，不是主键或不是唯一索引</p>
<p>（eq_ref和const的区别）</p>
<p><strong>eq_ref</strong>：唯一索引性扫描，对于每个索引键，表中只有一条记录与之匹配。常见于主键或唯一索引扫描</p>
<p><strong>const</strong>、<strong>system</strong>：单表中最多有一个匹配行，查询起来非常迅速，例如根据主键或唯一索引查询。system是const类型的特例，当查询的表只有一行的情况下，使用system。</p>
<p><strong>NULL</strong>：不用访问表或者索引，直接就能得到结果</p>
</li>
<li>
<p>possible_keys：MySQL 判断这条 SQL <strong>可能使用的索引</strong>，但不代表最终真的用了；</p>
</li>
<li>
<p>key：这个才表示 MySQL 最终实际选择使用的索引；</p>
</li>
<li>
<p>Extra：执行情况的说明和描述，常见有以下</p>
</li>
</ol>
<table>
<thead>
<tr>
<th>Extra</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Using index</code></td>
<td>使用覆盖索引，不需要回表读取完整行</td>
</tr>
<tr>
<td><code>Using where</code></td>
<td>还需要通过 WHERE 条件进行过滤</td>
</tr>
<tr>
<td><code>Using index condition</code></td>
<td>使用索引条件下推（ICP）</td>
</tr>
<tr>
<td><code>Using temporary</code></td>
<td>使用临时表</td>
</tr>
<tr>
<td><code>Using filesort</code></td>
<td>需要额外排序</td>
</tr>
<tr>
<td><code>Using index for group-by</code></td>
<td>GROUP BY / DISTINCT 等利用索引优化</td>
</tr>
<tr>
<td><code>Impossible WHERE</code></td>
<td>WHERE 条件不可能成立</td>
</tr>
<tr>
<td><code>Select tables optimized away</code></td>
<td>优化器可以直接得到结果，不需要按常规方式访问表</td>
</tr>
<tr>
<td><code>NULL</code></td>
<td>没有额外信息</td>
</tr>
</tbody>
</table>
<h3>6.3 修改索引</h3>
<p>MySQL 可以重命名索引：</p>
<pre><code>alter table t_user_multi1
    rename index idx_user_class_name to idx_user_class_username;
</code></pre>
<p>但不能直接修改索引包含的字段或字段顺序。此时应先删除旧索引，再创建新索引；把两个动作写在同一条 <code>ALTER TABLE</code> 中，可以清楚表达一次结构调整：</p>
<pre><code>alter table t_user_multi1
    drop index idx_user_class_username,
    add index idx_user_name_class (username, class_id);
</code></pre>
<p>MySQL 8.0 还可修改非主键索引的可见性，用于验证优化器不使用该索引时的执行计划：</p>
<pre><code>alter table t_user_multi1
    alter index idx_user_name_class invisible;

alter table t_user_multi1
    alter index idx_user_name_class visible;
</code></pre>
<p>不可见索引仍会在写入时维护，所以它不能减少索引的空间和写入成本。</p>
<h3>6.4 删除索引</h3>
<p>删除前先执行 <code>SHOW CREATE TABLE</code> 或 <code>SHOW INDEX</code>，原因是主键、唯一约束、外键和普通索引的删除语法及依赖关系不同。</p>
<h4>6.4.1 删除主键索引</h4>
<pre><code>alter table t_user_pk1
    drop primary key;
</code></pre>
<p>主键名固定为 <code>PRIMARY</code>，不能使用普通的 <code>DROP INDEX 索引名</code> 代替。若主键列带有 <code>auto_increment</code>，并且主键是支持该自增列的唯一索引，直接删除会报错。应先核对字段完整定义，再移除自增属性：</p>
<pre><code>-- 第一步：确认 unsigned、not null、default、comment 等原有属性
show create table t_user_pk1;

-- 第二步：只移除 auto_increment，其他需要保留的属性必须重新写全
alter table t_user_pk1
    modify id bigint unsigned not null;

-- 第三步：自增依赖解除后再删除主键
alter table t_user_pk1
    drop primary key;
</code></pre>
<p><code>MODIFY</code> 不会自动保留未写出的字段属性，因此不能不看原定义就机械执行 <code>MODIFY id BIGINT</code>。生产表还应先检查该主键是否被其他表的外键引用。</p>
<h4>6.4.2 删除普通、唯一或复合索引</h4>
<p>以下两种写法等价：</p>
<pre><code>drop index idx_user_phone on t_user_idx1;

alter table t_user_idx1
    drop index idx_user_phone;
</code></pre>
<p>删除唯一索引会同时取消它所实现的唯一性规则。删除复合索引会删除整个索引，不能只从索引中直接移除某一列；若需改变列组成，应删除后重建。</p>
<p>外键约束与支持外键的索引是两个对象。如果某索引仍被外键检查需要，MySQL 会拒绝直接删除；应先确认依赖，再决定是保留索引、建立替代索引，还是删除对应外键约束。</p>
<hr />
<h2>参考</h2>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-index-types.html">MySQL 8.0：InnoDB 聚集索引与二级索引</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-physical-structure.html">MySQL 8.0：InnoDB 索引物理结构</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/multiple-column-indexes.html">MySQL 8.0：复合索引</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/index-btree-hash.html">MySQL 8.0：B-tree 与 Hash 索引</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/create-index.html">MySQL 8.0：CREATE INDEX</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/explain.html">MySQL 8.0：EXPLAIN</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 多表查询：JOIN、子查询、UNION 与执行计划</title>
    <link href="https://blog.syrena.cn/posts/database-unionquery/database-unionquery/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-unionquery/database-unionquery/</id>
    <published>2026-07-28T00:00:00.000Z</published>
    <updated>2026-07-28T00:00:00.000Z</updated>
    <summary>系统学习MySQL多表连接、子查询、集合运算、结果写入及执行计划分析.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0（示例按 MySQL 8.0.39 编写）</p>
</blockquote>
<h2>1. 联合查询解决什么问题</h2>
<p>规范化会把实体拆到不同表中；读取完整业务信息时，需要重新组合数据</p>
<p>“联合查询”在本章中是一个宽泛概念，主要包括：</p>
<table>
<thead>
<tr>
<th>类型</th>
<th>解决的问题</th>
<th>常用语法</th>
</tr>
</thead>
<tbody>
<tr>
<td>表连接</td>
<td>横向组合有关联的表，增加列</td>
<td><code>join</code>、<code>left join</code></td>
</tr>
<tr>
<td>子查询</td>
<td>把一个查询的结果交给另一个查询使用</td>
<td><code>in</code>、<code>exists</code>、标量子查询</td>
</tr>
<tr>
<td>集合查询</td>
<td>纵向合并多个结构相同的结果集，增加行</td>
<td><code>union</code>、<code>union all</code></td>
</tr>
<tr>
<td>查询结果写入</td>
<td>把查询出的行保存到表中</td>
<td><code>insert ... select</code>、<code>create table ... select</code></td>
</tr>
</tbody>
</table>
<p>外键不会自动连接表，查询仍须写连接条件</p>
<h2>2. 多表查询的逻辑与实际执行</h2>
<h3>2.1 用笛卡尔积理解逻辑结果</h3>
<p>若 A 表有 3 行、B 表有 4 行，交叉连接会产生 <code>3 × 4 = 12</code> 行：</p>
<pre><code>select *
from table_a cross join table_b;
</code></pre>
<p>内连接可在逻辑上理解为“组合后保留满足条件的行”：</p>
<pre><code>select s.name, c.class_name
from student_design2 s
join class_design2 c on c.id = s.class_id;
</code></pre>
<p>漏写条件会使行数成倍膨胀；一对多连接还会按匹配数重复左侧行</p>
<h3>2.2 MySQL 不一定真的生成完整笛卡尔积</h3>
<p>笛卡尔积是逻辑模型，不等于物理执行。优化器会根据统计信息、索引和成本：</p>
<ol>
<li>决定表的连接顺序，而不一定按 SQL 中的书写顺序</li>
<li>为每张表选择全表扫描、索引范围扫描或索引查找</li>
<li>选择嵌套循环连接或 Hash Join 等算法</li>
<li>尽早过滤无效行</li>
</ol>
<p>MySQL 常用嵌套循环，合适的连接索引可加快内层查找。8.0.18 起支持 Hash Join，8.0.20 起用它替代 Block Nested-Loop 的场景</p>
<p>SQL 的逻辑处理顺序可简化为：</p>
<p><img src="./unionQuery0.png" alt="" /></p>
<hr />
<h2>3. 内连接 INNER JOIN</h2>
<h3>3.1 语法</h3>
<pre><code>select 查询列
from 表1 别名1
[inner] join 表2 别名2 on 连接条件
where 普通过滤条件;
</code></pre>
<p><code>join</code> 默认就是 <code>inner join</code>，内连接只返回两边能够匹配的行。</p>
<p><code>from a, b where ...</code> 也能表达内连接，但工程中推荐显式 <code>join ... on</code>，因为它把连接条件与普通过滤分开，更不容易漏写条件</p>
<h3>3.2 表别名与列歧义</h3>
<p>多个表都有 <code>id</code>、<code>name</code> 时，裸写列名可能出现错误 1052。应使用短别名和 <code>别名.列名</code>：</p>
<pre><code>select
    s.id as student_id,
    s.name as student_name,
    c.class_name
from student_design2 s
join class_design2 c on c.id = s.class_id
where s.name = '孙悟空';
</code></pre>
<h3>3.3 三表、四表连接</h3>
<p>查询每名学生的课程与成绩：</p>
<pre><code>select
    s.sno,
    s.name,
    c.course_name,
    sc.score
from student_design2 s
join score_design2 sc on sc.student_id = s.id
join course_design2 c on c.id = sc.course_id
order by s.id, c.id;
</code></pre>
<p>每增加一张表都要确认连接列和连接基数；结果异常时检查条件、列和源数据。</p>
<h3>3.4 连接后分组</h3>
<p>统计每名学生已出分课程数和平均分：</p>
<pre><code>select
    s.id,
    s.name,
    count(sc.score) as graded_count,
    avg(sc.score) as avg_score
from student_design2 s
join score_design2 sc on sc.student_id = s.id
group by s.id, s.name;
</code></pre>
<p><code>count(sc.score)</code> 不统计 <code>NULL</code>，适合统计已出分课程；<code>count(*)</code> 统计结果行。开启 <code>only_full_group_by</code> 时，查询列应参与分组、被聚合，或能被分组列函数依赖</p>
<hr />
<h2>4. 外连接 OUTER JOIN</h2>
<h3>4.1 左连接与右连接</h3>
<pre><code>-- 保留左表全部行
select ...
from 表1 left join 表2 on 连接条件;

-- 保留右表全部行
select ...
from 表1 right join 表2 on 连接条件;
</code></pre>
<p><code>left join</code> 保留左表全部行，右侧无匹配时补 <code>NULL</code>。交换表顺序即可把右连接改为左连接，项目中统一左连接通常更易读</p>
<p>统计所有班级人数，包括 0 人班级：</p>
<pre><code>select
    c.id,
    c.class_name,
    count(s.id) as student_count
from class_design2 c
left join student_design2 s on s.class_id = c.id
group by c.id, c.class_name;
</code></pre>
<p>必须用 <code>count(s.id)</code>；若用 <code>count(*)</code>，外连接产生的空行会让空班级被统计为 1</p>
<h3>4.2 查找“没有关联记录”的数据</h3>
<p>查找没有任何选课记录的学生：</p>
<pre><code>select s.id, s.sno, s.name
from student_design2 s
left join score_design2 sc on sc.student_id = s.id
where sc.student_id is null;
</code></pre>
<p>应检查右表中本来就不允许为 <code>NULL</code> 的主键或外键列。不要用
<code>sc.score is null</code> 判断“没有记录”，因为本系统允许 <code>score</code> 为 <code>NULL</code>
表示已选课但尚未出分。</p>
<p>这叫反连接模式，也可用 <code>not exists</code> 表达</p>
<h3>4.3 ON 与 WHERE 的关键区别</h3>
<p><code>on</code> 决定如何匹配，<code>where</code> 过滤连接后的结果。右表条件放在 <code>where</code> 中会删除补出的 <code>NULL</code> 行，使左连接近似退化为内连接：</p>
<pre><code>-- 只返回存在及格成绩的学生
select s.name, sc.score
from student_design2 s
left join score_design2 sc on sc.student_id = s.id
where sc.score &gt;= 60;
</code></pre>
<p>要保留所有学生，只匹配及格成绩，应把条件写入 <code>on</code>：</p>
<pre><code>select s.name, sc.score
from student_design2 s
left join score_design2 sc
    on sc.student_id = s.id and sc.score &gt;= 60;
</code></pre>
<h3>4.4 全外连接</h3>
<p>MySQL 8.0 没有原生 <code>full outer join</code>，可用“左连接 + 反向左连接的未匹配部分”：</p>
<pre><code>select a.id, b.id
from table_a a
left join table_b b on b.id = a.id
union all
select a.id, b.id
from table_b b
left join table_a a on a.id = b.id
where a.id is null;
</code></pre>
<hr />
<h2>5. 自连接 SELF JOIN</h2>
<p>自连接不是新关键字，而是同一张表在一个查询中扮演不同角色，必须使用不同别名。比较同一学生的 MySQL 与 Java 成绩：</p>
<pre><code>select s.name, m.score as mysql_score, j.score as java_score
from score_design2 m
join score_design2 j on j.student_id = m.student_id
join student_design2 s on s.id = m.student_id
join course_design2 cm on cm.id = m.course_id
join course_design2 cj on cj.id = j.course_id
where cm.course_name = 'MySQL'
  and cj.course_name = 'Java'
  and m.score &gt; j.score;
</code></pre>
<p>用 <code>m</code>、<code>j</code> 区分两种成绩，用 <code>student_id</code> 保证比较同一学生。</p>
<hr />
<h2>6. 子查询</h2>
<p>子查询嵌套在另一条语句中，其用法取决于返回一个值、一行、多行还是结果表。</p>
<h3>6.1 标量子查询：返回一个值</h3>
<p>查询高于全体平均分的成绩：</p>
<pre><code>select student_id, course_id, score
from score_design2
where score &gt; (
    select avg(score) from score_design2
);
</code></pre>
<p>单值比较要求子查询至多返回一行一列；返回多行会出现错误 1242。</p>
<h3>6.2 多行子查询：IN</h3>
<p>查询 Java 或 MySQL 课程的成绩：</p>
<pre><code>select *
from score_design2
where course_id in (
    select id
    from course_design2
    where course_name in ('Java', 'MySQL')
);
</code></pre>
<p><code>in</code> 表示等于集合中的任意值。若子查询含 <code>NULL</code>，<code>not in</code> 可能得到
<code>unknown</code> 而查不到行；排除关联记录时优先用 <code>not exists</code>，或显式排除
<code>NULL</code>。</p>
<h3>6.3 EXISTS 与关联子查询</h3>
<p>查询至少选过一门课的学生：</p>
<pre><code>select s.id, s.name
from student_design2 s
where exists (
    select 1
    from score_design2 sc
    where sc.student_id = s.id
);
</code></pre>
<p>内层引用外层 <code>s.id</code>，属于关联子查询。<code>exists</code> 只判断匹配行是否存在；没有选课则用 <code>not exists</code>。</p>
<p>子查询不一定比连接慢。MySQL 可能将 <code>in</code>、<code>exists</code> 转为半连接或物化结果，应通过执行计划验证。</p>
<h3>6.4 多列子查询</h3>
<p>行构造器可让多个列作为一个整体比较：</p>
<pre><code>select *
from score_demo
where (student_id, course_id, score) in (
    select student_id, course_id, score
    from score_demo
    group by student_id, course_id, score
    having count(*) &gt; 1
);
</code></pre>
<p>内外列的数量、顺序、类型须对应。正式表已有复合主键，重复选课应在写入时被拒绝</p>
<h3>6.5 FROM 中的子查询与 CTE</h3>
<p><code>from</code> 中的子查询称为派生表，MySQL 要求给它别名：</p>
<pre><code>select t.class_id, t.avg_score
from (
    select s.class_id, avg(sc.score) as avg_score
    from student_design2 s
    join score_design2 sc on sc.student_id = s.id
    group by s.class_id
) t
where t.avg_score &gt;= 80;
</code></pre>
<p>MySQL 8.0 还可用 <code>with 名称 as (子查询)</code> 定义 CTE，提高复杂查询可读性。派生表或 CTE 不代表一定创建磁盘临时表；优化器可能合并它，也可能物化后使用</p>
<h3>6.6 如何选择</h3>
<p>返回关联表列用 <code>join</code>；判断存在性用 <code>exists</code>；与单值比较用标量子查询；与集合比较用 <code>in</code>；复杂逻辑可用 CTE。先保证语义清楚，再验证性能</p>
<hr />
<h2>7. 集合查询</h2>
<p>连接是在同一行上横向补列；集合操作把多个查询的结果纵向叠加</p>
<h3>7.1 UNION 与 UNION ALL</h3>
<pre><code>select sno, name from current_student
union
select sno, name from archived_student;

select sno, name from current_student
union all
select sno, name from archived_student;
</code></pre>
<ul>
<li><code>union</code> 默认去除完全相同的结果行，需要额外的去重工作</li>
<li><code>union all</code> 保留重复行，通常更快；业务允许重复时优先考虑</li>
<li>各查询必须返回相同列数，同一位置的数据类型应兼容</li>
<li>最终列名取第一个查询的列名或别名</li>
<li>整体排序应在最后写一次，并使用最终结果的列名：</li>
</ul>
<pre><code>select sno, name from current_student
union all
select sno, name from archived_student
order by sno;
</code></pre>
<h3>7.2 MySQL 8.0.31 之后的集合运算</h3>
<p>MySQL 8.0.31 新增 <code>intersect</code>（交集）和 <code>except</code>（差集）：</p>
<pre><code>查询1 intersect [all | distinct] 查询2;

查询1 except [all | distinct] 查询2;
</code></pre>
<p>8.0.39 支持；旧版需用连接改写。集合运算默认 <code>distinct</code>。</p>
<hr />
<h2>8. 保存查询结果</h2>
<h3>8.1 INSERT ... SELECT</h3>
<p>把查询结果插入已有表：</p>
<pre><code>insert into excellent_student (student_id, avg_score)
select student_id, avg(score)
from score_design2
group by student_id
having avg(score) &gt;= 90;
</code></pre>
<p>目标列与查询列的数量、顺序、类型须对应，并应显式写目标列名。目标表约束仍会执行；自增列通常省略</p>
<p>它只复制数据。大量迁移前先单独核对 <code>select</code>，并按原子性要求使用事务</p>
<h3>8.2 CREATE TABLE ... SELECT</h3>
<p>根据查询结果直接创建新表：</p>
<pre><code>create table class_score_report as
select
    s.class_id,
    count(sc.score) as graded_count,
    avg(sc.score) as avg_score
from student_design2 s
join score_design2 sc on sc.student_id = s.id
group by s.class_id;
</code></pre>
<p>它适合临时报表，但不会自动创建索引，<code>auto_increment</code> 等属性也可能丢失，表达式应起别名。复制同构表通常使用：</p>
<pre><code>create table student_backup like student_design2;
insert into student_backup select * from student_design2;
</code></pre>
<p><code>like</code> 复制字段属性和索引，但不复制外键；完成后用 <code>show create table</code> 检查</p>
<hr />
<h2>9. 用 EXPLAIN 理解执行计划</h2>
<p>不要凭 SQL 外观猜性能，应查看执行计划：</p>
<pre><code>explain analyze
select s.name, sc.score
from student_design2 s
join score_design2 sc on sc.student_id = s.id;
</code></pre>
<p><code>explain</code> 展示估算计划；<code>explain analyze</code> 会实际执行并显示行数与耗时，不要对高风险语句随意使用</p>
<p>重点检查：</p>
<ol>
<li>实际连接顺序及各步读取行数</li>
<li><code>key</code> 是否使用预期索引，是否出现不必要的 <code>ALL</code> 扫描</li>
<li>估算与实际行数是否差异很大</li>
<li>是否出现 Hash Join、临时表、排序或大量循环</li>
</ol>
<p>连接列两侧类型应一致，被驱动表的连接列需要可用索引；复合索引应依据真实筛选和排序设计。索引会增加写入成本，并非越多越好</p>
<hr />
<h2>10. 常见错误与排查</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>常见原因</th>
<th>检查方法</th>
</tr>
</thead>
<tbody>
<tr>
<td>结果行数异常巨大</td>
<td>漏写条件，或误判连接基数</td>
<td>分步连接并统计行数</td>
</tr>
<tr>
<td>错误 1052</td>
<td>多表存在同名列</td>
<td>使用 <code>表别名.列名</code></td>
</tr>
<tr>
<td>左连接查不到无匹配行</td>
<td>右表条件写进了 <code>where</code></td>
<td>判断条件是否应移入 <code>on</code></td>
</tr>
<tr>
<td>无记录被误判</td>
<td>检查了可为 <code>NULL</code> 的业务列</td>
<td>检查右表非空主键/外键</td>
</tr>
<tr>
<td>错误 1242</td>
<td>标量子查询返回多行</td>
<td>改用 <code>in</code>，或保证只返回一行</td>
</tr>
<tr>
<td><code>not in</code> 意外返回空集</td>
<td>子查询含 <code>NULL</code></td>
<td>改用 <code>not exists</code> 或排除 <code>NULL</code></td>
</tr>
<tr>
<td><code>union</code> 报列数错误</td>
<td>各查询列数不同</td>
<td>逐个执行并核对列的位置</td>
</tr>
<tr>
<td>聚合结果偏大</td>
<td>连接先重复了事实行</td>
<td>确认表粒度和连接基数</td>
</tr>
</tbody>
</table>
<p>排查时先验证单表条件，再一次加入一张表和一个 <code>on</code>，比较每步行数，最后添加分组与排序。连接错误进入聚合后，常只留下看似合理的错误数字</p>
<hr />
<h2>11. 综合查询</h2>
<p>查询所有学生的班级、已选课程数、平均分，并保留没有选课的学生：</p>
<pre><code>select
    s.id,
    s.sno,
    s.name,
    c.class_name,
    count(sc.course_id) as course_count,
    round(avg(sc.score), 2) as avg_score
from student_design2 s
join class_design2 c on c.id = s.class_id
left join score_design2 sc on sc.student_id = s.id
group by s.id, s.sno, s.name, c.class_name
order by avg_score desc, s.id;
</code></pre>
<p>设计原因：</p>
<ol>
<li>学生必须属于有效班级，所以学生与班级使用内连接。</li>
<li>学生可以暂时没有选课，所以成绩使用左连接。</li>
<li><code>count(sc.course_id)</code> 让无选课学生得到 0，而不是 1。</li>
<li><code>avg</code> 忽略未出分的 <code>NULL</code>；没有有效成绩时仍为 <code>NULL</code>，不同于 0 分。</li>
<li>聚合发生在连接之后，因此分组必须与“一名学生一行”的目标粒度一致。</li>
</ol>
<hr />
<h2>12. 总结</h2>
<ol>
<li>内连接只保留匹配行；外连接还保留指定一侧的未匹配行。</li>
<li><code>on</code> 决定如何匹配，<code>where</code> 过滤连接结果；自连接靠角色别名区分。</li>
<li>子查询按结果形态选择 <code>=</code>、<code>in</code>、<code>exists</code> 或派生表，<code>not in</code> 要警惕 <code>NULL</code>。</li>
<li><code>union</code> 去重，<code>union all</code> 保留重复且通常更高效。</li>
<li><code>insert ... select</code> 写入已有表；<code>create table ... select</code> 创建表并写入。</li>
<li>笛卡尔积解释逻辑语义，物理执行和性能必须通过 <code>explain</code> 验证。</li>
</ol>
<hr />
<h2>参考</h2>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/nested-loop-joins.html">MySQL 8.0：嵌套循环连接</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/hash-joins.html">MySQL 8.0：Hash Join 优化</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/subquery-optimization.html">MySQL 8.0：子查询优化</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/union.html">MySQL 8.0：UNION</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/insert-select.html">MySQL 8.0：INSERT ... SELECT</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/create-table-select.html">MySQL 8.0：CREATE TABLE ... SELECT</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/using-explain.html">MySQL 8.0：使用 EXPLAIN 优化查询</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>关系数据库设计：实体关系、函数依赖与三大范式</title>
    <link href="https://blog.syrena.cn/posts/database-design/database-design/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-design/database-design/</id>
    <published>2026-07-26T00:00:00.000Z</published>
    <updated>2026-07-26T00:00:00.000Z</updated>
    <summary>认识MySQL数据库设计原理.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0（示例按 MySQL 8.0.39 编写）</p>
</blockquote>
<h2>1. 数据库设计</h2>
<p>数据库设计首先考虑的是以下问题：</p>
<ol>
<li>业务中有哪些需要长期保存的对象？</li>
<li>每个对象应该保存哪些属性？</li>
<li>一行数据代表什么，即表的粒度是什么？</li>
<li>哪些字段能够唯一标识一行？</li>
<li>对象之间是一对一、一对多还是多对多？</li>
<li>哪些数据会重复，修改时是否可能产生不一致？</li>
<li>常用查询是什么，需要怎样的索引？</li>
</ol>
<h3>1.1 类、实体与表</h3>
<table>
<thead>
<tr>
<th>分析层次</th>
<th>含义</th>
<th>学生系统示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>概念类</td>
<td>从现实业务中抽象出的对象</td>
<td>学生、班级、课程</td>
</tr>
<tr>
<td>实体</td>
<td>需要长期保存的数据对象</td>
<td>某个具体学生</td>
</tr>
<tr>
<td>表</td>
<td>实体在关系数据库中的存储结构</td>
<td><code>student_design2</code></td>
</tr>
<tr>
<td>属性</td>
<td>实体具有的特征</td>
<td>学号、姓名、出生日期</td>
</tr>
<tr>
<td>列</td>
<td>属性在表中的实现</td>
<td><code>sno</code>、<code>name</code>、<code>birthday</code></td>
</tr>
</tbody>
</table>
<p>类、实体和表经常近似一一对应，但不能机械地认为“一个 Java 类必须是一张表”。DTO、VO 通常不需要建表，集合属性往往要设计为子表或中间表。</p>
<hr />
<h2>2. 部分概念</h2>
<h3>2.1 表的粒度</h3>
<p>粒度指“一行数据到底描述什么”</p>
<p>例如，成绩表的一行可以定义为：</p>
<blockquote>
<p>一名学生对一门课程的最终成绩。</p>
</blockquote>
<p>于是 <code>(student_id, course_id)</code> 可以唯一确定一行。如果业务改为“一名学生同一门课程可以补考多次”，原粒度就不够了，应增加 <code>attempt_no</code>，或单独设计考试场次表。</p>
<h3>2.2 候选键、主键与业务键</h3>
<ul>
<li><strong>候选键</strong>：能够唯一标识一行的最小字段集合。</li>
<li><strong>主键</strong>：从候选键中选出的主要行标识。</li>
<li><strong>业务键</strong>：业务本身具有唯一性的字段，如学号、课程编号。</li>
<li><strong>代理键</strong>：没有业务含义、专门用于标识行的字段，如自增 <code>id</code>。</li>
</ul>
<p>学生表可以使用自增 <code>id</code> 作为主键，同时给 <code>sno</code> 添加唯一约束。这样连接时主键稳定，数据库又能保证学号不重复。</p>
<h3>2.3 函数依赖</h3>
<p>若已知 A 的值，就能唯一确定 B 的值，可以写成：</p>
<pre><code>A → B
</code></pre>
<p>例如：</p>
<pre><code>学号 → 学生姓名
课程编号 → 课程名、学分
(学号, 课程编号) → 成绩
</code></pre>
<p>范式的核心就是检查：每个非关键字段究竟依赖谁。</p>
<h2>3. 数据库范式</h2>
<p>范式是一组减少重复和异常的设计规则。常见范式包括 1NF、2NF、3NF、BCNF、4NF 和 5NF。一般业务系统先把表设计到第三范式，再根据明确的性能证据决定是否反范式。</p>
<h3>3.1 第一范式</h3>
<p><strong>一列保存一个业务意义上的值</strong></p>
<p>第一范式（1NF）要求每个字段在当前业务语义下是原子的，不能在一列中保存一组可独立查询和维护的值。</p>
<p>反例：</p>
<table>
<thead>
<tr>
<th>student_id</th>
<th>name</th>
<th>course_ids</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>张三</td>
<td><code>1,2,5</code></td>
</tr>
</tbody>
</table>
<p><code>course_ids</code> 保存了多个课程编号，难以使用外键、连接和普通索引，也容易在增删编号时破坏格式。</p>
<p>正确方式是把选课关系拆成多行：</p>
<table>
<thead>
<tr>
<th>student_id</th>
<th>course_id</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
</tr>
<tr>
<td>1</td>
<td>5</td>
</tr>
</tbody>
</table>
<blockquote>
<p>“原子”与业务语义有关。地址在只展示的场景中可以是一个字符串；若要按省、市统计，就应拆成独立字段或关联地区表。因此，字段使用了 MySQL 基本数据类型，并不代表设计天然合理。</p>
</blockquote>
<h3>3.2 第二范式</h3>
<p><strong>消除对复合候选键的部份依赖</strong></p>
<p>第二范式（2NF）建立在 1NF 之上，要求每个非关键字段依赖候选键的全部字段，而不是只依赖其中一部分。</p>
<p>假设把所有数据放进一张表：</p>
<pre><code>student_course(
  sno, course_code, student_name, birthday,
  course_name, credit, score
)
</code></pre>
<p>候选键是 <code>(sno, course_code)</code>，但实际依赖关系为：</p>
<pre><code>sno → student_name, birthday
course_code → course_name, credit
(sno, course_code) → score
</code></pre>
<p>学生信息只依赖 <code>sno</code>，课程信息只依赖 <code>course_code</code>，因此发生了部分依赖，应拆为：</p>
<ul>
<li>学生表：保存 <code>sno → 学生属性</code>。</li>
<li>课程表：保存 <code>course_code → 课程属性</code>。</li>
<li>成绩表：保存 <code>(sno, course_code) → score</code>。</li>
</ul>
<p>不满足 2NF 容易产生四类异常：</p>
<ol>
<li><strong>数据冗余</strong>：学生姓名和课程学分反复保存。</li>
<li><strong>更新异常</strong>：修改学分时必须更新多行，可能出现不同值。</li>
<li><strong>插入异常</strong>：还没有学生选课时，无法单独录入新课程。</li>
<li><strong>删除异常</strong>：删除最后一条成绩时，课程信息也被误删。</li>
</ol>
<p>如果所有候选键都是单列，就不存在“只依赖键的一部分”，因此不会违反 2NF；但仍可能违反 3NF。</p>
<h3>3.3 第三范式</h3>
<p><strong>消除非关键字段的传递依赖</strong></p>
<p>第三范式（3NF）建立在 2NF 之上，重点消除非关键字段通过另一个非关键字段间接依赖候选键的情况。</p>
<p>反例：</p>
<pre><code>student(
  id, sno, name, college_id,
  college_name, college_phone
)
</code></pre>
<p>依赖链为：</p>
<pre><code>id → college_id → college_name, college_phone
</code></pre>
<p>学院名称和电话描述的是学院，不是学生。若每个学生都保存一份学院电话，修改时就会产生大量重复更新。应拆成：</p>
<pre><code>student(id, sno, name, college_id)
college(id, college_name, college_phone)
</code></pre>
<p>记忆方式：</p>
<blockquote>
<p>非关键字段应依赖于键、依赖于整个键，并且只依赖于键。</p>
</blockquote>
<h3>3.4 范式不是拆表越多越好</h3>
<p>范式主要保证一致性和可维护性，不直接保证查询最快。拆表过多会增加连接查询和维护成本；反范式则会增加重复数据及同步成本。</p>
<p>合理顺序是先按 3NF 建立正确模型，再用真实慢查询和执行计划确认瓶颈。只有收益明确时，才增加汇总表或缓存列，并设计可靠的同步机制；不要仅凭“以后可能更快”就提前反范式。</p>
<hr />
<h2>4. E-R 图与关系落地</h2>
<p>E-R 图用于在写 SQL 前表达实体、属性、关系、基数和可选性。矩形通常表示实体，属性描述实体特征，连线表示关系。</p>
<p><img src="./design0.png" alt="" /></p>
<h3>4.1 一对一关系（1:1）</h3>
<p>一对一关系必须同时保证“能够引用”和“不能重复引用”。只添加普通 <code>account_id</code> 并不能阻止多个用户指向同一个账户。</p>
<p>最严格的实现是让从表主键同时充当外键：</p>
<pre><code>create table user_profile (
    user_id bigint unsigned primary key,
    nickname varchar(30),
    constraint fk_user_profile_user
        foreign key (user_id) references users(id)
);
</code></pre>
<p>因为 <code>user_id</code> 是主键，所以同一用户最多只有一份档案。若使用独立主键，则关联列必须再加 <code>unique</code>。外键放在哪一侧，应根据生命周期和访问方向决定。</p>
<h3>4.2 一对多关系（1:N）</h3>
<p>把“一”方主键保存到“多”方：</p>
<pre><code>class_design2.id ← student_design2.class_id
</code></pre>
<p>原因是一个班级对应多名学生，而每名学生只需要保存一个所属班级编号。不要在班级表中保存用逗号拼接的学生编号。</p>
<h3>4.3 多对多关系（M:N）</h3>
<p>关系数据库不能用一个普通外键直接表达多对多，需要建立中间表：</p>
<pre><code>student_design2
        ↓ 1:N
score_design2
        ↑ N:1
course_design2
</code></pre>
<p>中间表不仅保存两端主键，还可以保存关系自身的属性，如成绩、选课时间、状态。若业务规定同一学生只能选同一课程一次，应使用复合主键或联合唯一约束防止组合重复。</p>
<h2>5. 从需求到建表的设计过程</h2>
<h2><img src="./design1.png" alt="" /></h2>
<p>操作顺序不能颠倒：先澄清能否退课、重修和多次考试等规则，才能确定实体粒度；再用候选键和基数表达关系，通过范式检查消除错误依赖；最后根据数据范围选择类型与约束，并用真实查询设计索引。建表后还要主动测试重复学号、无效班级、重复选课和越界成绩。</p>
<h2>6. 学生选课系统完整设计</h2>
<p>下面是我练习用的四张练习表。</p>
<pre><code>create database if not exists design_study
    character set utf8mb4
    collate utf8mb4_0900_ai_ci;

use design_study;

drop table if exists score_design2;
drop table if exists student_design2;
drop table if exists course_design2;
drop table if exists class_design2;

create table class_design2 (
    id bigint unsigned primary key auto_increment,
    class_name varchar(30) not null,
    constraint uk_class_design2_name unique (class_name)
) engine = InnoDB;

create table course_design2 (
    id bigint unsigned primary key auto_increment,
    course_code varchar(20) not null,
    course_name varchar(50) not null,
    credit decimal(3, 1) not null,
    constraint uk_course_design2_code unique (course_code),
    constraint ck_course_design2_credit
        check (credit between 0.5 and 20.0)
) engine = InnoDB;

create table student_design2 (
    id bigint unsigned primary key auto_increment,
    sno varchar(20) not null,
    name varchar(30) not null,
    birthday date,
    gender tinyint unsigned,
    enroll_date date not null,
    class_id bigint unsigned not null,
    constraint uk_student_design2_sno unique (sno),
    constraint ck_student_design2_gender
        check (gender in (0, 1, 2)),
    constraint fk_student_design2_class
        foreign key (class_id)
        references class_design2(id)
        on delete restrict
) engine = InnoDB;

create table score_design2 (
    student_id bigint unsigned not null,
    course_id bigint unsigned not null,
    score decimal(5, 2),
    selected_at datetime not null default current_timestamp,
    constraint pk_score_design2
        primary key (student_id, course_id),
    constraint ck_score_design2_score
        check (score is null or score between 0 and 100),
    constraint fk_score_design2_student
        foreign key (student_id)
        references student_design2(id)
        on delete restrict,
    constraint fk_score_design2_course
        foreign key (course_id)
        references course_design2(id)
        on delete restrict
) engine = InnoDB;
</code></pre>
<h3>6.1 关键设计原因</h3>
<ul>
<li>学号使用 <code>varchar</code>，因为编号可能有前导 0，也不参与数学运算。</li>
<li>保存 <code>birthday</code> 而不是 <code>age</code>，因为年龄会随时间变化，可在查询时计算。</li>
<li>学分和成绩使用 <code>decimal</code>，避免 <code>float</code> 的近似值误差。</li>
<li><code>score</code> 允许为 <code>null</code>，用于表示已经选课但尚未出分；<code>0</code> 表示实际得了 0 分。</li>
<li>成绩表使用 <code>(student_id, course_id)</code> 复合主键，直接表达“一名学生一门课程一条记录”。</li>
<li><code>on delete restrict</code> 防止误删学生或课程后连带丢失成绩。是否级联删除必须由业务规则决定。</li>
<li>班级表不保存 <code>student_count</code>，因为人数可从学生表统计，重复保存会产生同步问题。</li>
</ul>
<p>如果需要记录补考或多次考试，应将主键改为 <code>(student_id, course_id, attempt_no)</code>，或增加考试场次表，不能继续假设一门课程只有一个成绩。</p>
<h2>7. 常用验证与查询</h2>
<h3>7.1 检查实际表结构</h3>
<pre><code>show create table score_design2;
show index from score_design2;
</code></pre>
<p><code>SHOW CREATE TABLE</code> 用于检查主键、外键、检查约束和引擎；<code>SHOW INDEX</code> 用于检查索引列顺序。设计文档写了什么不重要，数据库实际创建出的结构才是最终结果。</p>
<h3>7.2 统计班级人数</h3>
<pre><code>select
    c.id,
    c.class_name,
    count(s.id) as student_count
from class_design2 c
left join student_design2 s on s.class_id = c.id
group by c.id, c.class_name;
</code></pre>
<p>使用 <code>left join</code> 是为了让暂时没有学生的班级也能显示为 0 人。</p>
<h2>参考资料</h2>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/create-table-check-constraints.html">MySQL 8.0 Reference Manual：CHECK Constraints</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html">MySQL 8.0 Reference Manual：FOREIGN KEY Constraints</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/fixed-point-types.html">MySQL 8.0 Reference Manual：DECIMAL and NUMERIC</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.0/en/numeric-type-syntax.html">MySQL 8.0 Reference Manual：Numeric Data Type Syntax</a></li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 约束：数据完整性、主外键与检查规则</title>
    <link href="https://blog.syrena.cn/posts/database-constraint/database-constraint/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-constraint/database-constraint/</id>
    <published>2026-07-25T00:00:00.000Z</published>
    <updated>2026-07-25T00:00:00.000Z</updated>
    <summary>了解数据库约束，学习约束语法.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>适用环境：MySQL 8.0（本文实操按 MySQL 8.0.39 编写）</p>
</blockquote>
<h2>1. 数据库约束</h2>
<h3>1.1 概念</h3>
<p>数据库约束（<strong>Constraint</strong>）是数据库对表中数据设置的规则。</p>
<p>当执行 <code>insert</code>、<code>update</code> 或某些 <code>delete</code> 操作时，MySQL 会检查数据是否满足这些规则：</p>
<ul>
<li>满足规则：允许操作</li>
<li>不满足规则：拒绝操作并返回错误</li>
</ul>
<p>比如，一个学生系统可能有以下要求：</p>
<ul>
<li>学生姓名不能为空</li>
<li>年龄不小于 16 岁</li>
<li>每名学生的学号不能重复</li>
<li>每条学生记录必须有一个唯一编号</li>
<li>学生填写的班级编号必须在班级表中真实存在</li>
</ul>
<p>如果只把这些要求写在需求文档中，数据库并不会自动遵守;</p>
<p>只有把它们定义为约束，数据库才会在每次写入时主动检查</p>
<blockquote>
<p>可以把约束理解为数据库的“数据守门员”。应用程序可以提前校验用户输入，但数据库约束负责守住最终的数据底线。</p>
</blockquote>
<h3>1.2  数据完整性的四个角度</h3>
<h3><img src="./constraint0.png" alt="" /></h3>
<p>这张图说明：</p>
<ul>
<li><code>primary key</code> 解决“这一行是谁”的问题；</li>
<li><code>not null</code>、<code>default</code>、<code>check</code> 和数据类型共同限制“这一列可以放什么”；</li>
<li><code>unique</code> 解决“业务值不能重复”的问题；</li>
<li><code>foreign key</code> 解决“引用的另一行是否存在”的问题；</li>
</ul>
<h3>1.3 常用约束</h3>
<table>
<thead>
<tr>
<th>约束</th>
<th>中文名称</th>
<th>核心作用</th>
<th>典型字段</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>not null</code></td>
<td>非空约束</td>
<td>不允许列值为 SQL <code>null</code></td>
<td>姓名、创建时间</td>
</tr>
<tr>
<td><code>default</code></td>
<td>默认值约束</td>
<td>未指定列值时自动使用默认值</td>
<td>状态、年龄、创建时间</td>
</tr>
<tr>
<td><code>unique</code></td>
<td>唯一约束</td>
<td>限制一列或一组列的非空组合不能重复</td>
<td>学号、手机号、用户名</td>
</tr>
<tr>
<td><code>primary key</code></td>
<td>主键约束</td>
<td>唯一且非空地标识每一行</td>
<td><code>id</code></td>
</tr>
<tr>
<td><code>foreign key</code></td>
<td>外键约束</td>
<td>保证从表引用的主表记录存在</td>
<td><code>class_id</code>、<code>student_id</code></td>
</tr>
<tr>
<td><code>check</code></td>
<td>检查约束</td>
<td>限制值范围或列之间的关系</td>
<td>年龄、分数、开始结束时间</td>
</tr>
</tbody>
</table>
<h3>1.4 列级表级写法</h3>
<p>约束可以写在列定义后面，也可以单独写成表级约束。</p>
<h4>列级写法</h4>
<pre><code>create table constraint_demo (
    id bigint primary key auto_increment,
    name varchar(20) not null,
    age int default 18,
    sno varchar(20) unique,
    score decimal(5, 2) check (score between 0 and 100)
);
</code></pre>
<p>这种写法简洁，适合只涉及一个字段的约束。</p>
<h4>表级写法</h4>
<pre><code>create table constraint_demo (
    id bigint auto_increment,
    name varchar(20) not null,
    sno varchar(20),
    score decimal(5, 2),

    constraint pk_constraint_demo primary key (id),
    constraint uk_constraint_demo_sno unique (sno),
    constraint ck_constraint_demo_score
        check (score between 0 and 100)
);
</code></pre>
<p>表级写法<strong>优点</strong>：</p>
<ol>
<li>可以给约束<strong>起名称</strong>，后续删除和排错更方便。</li>
<li>可以定义包含多个字段的复合约束。</li>
</ol>
<p>本文在综合建表中优先采用“字段定义在前、命名约束集中放在后面”的写法，原因是结构更清晰，也更符合正式项目的维护需求。</p>
<h3>1.5 约束命名</h3>
<table>
<thead>
<tr>
<th>对象</th>
<th>推荐前缀</th>
<th>示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>主键约束</td>
<td><code>pk_</code></td>
<td><code>pk_student_design2</code></td>
</tr>
<tr>
<td>唯一约束或唯一索引</td>
<td><code>uk_</code></td>
<td><code>uk_student_design2_sno</code></td>
</tr>
<tr>
<td>外键约束</td>
<td><code>fk_</code></td>
<td><code>fk_student_design2_class</code></td>
</tr>
<tr>
<td>检查约束</td>
<td><code>ck_</code></td>
<td><code>ck_score_design2_score</code></td>
</tr>
<tr>
<td>普通索引</td>
<td><code>idx_</code></td>
<td><code>idx_student_design2_name</code></td>
</tr>
</tbody>
</table>
<p>命名的目的是让错误信息、建表语句和后续修改更容易读懂</p>
<p><strong>注意</strong>：MySQL 中主键对应的索引名称固定显示为 <code>PRIMARY</code>。<code>pk_</code> 名称仍可用于设计文档、迁移脚本的可读性以及其他数据库系统，但不要依赖 MySQL 一定保留自定义主键名称。</p>
<hr />
<h2>2. NOT NULL 非空约束</h2>
<h3>2.1 作用</h3>
<p><code>not null</code> 表示该列不能保存 SQL 中的空值 <code>null</code>。</p>
<p>例如，学生姓名是识别学生的基本信息。如果允许姓名为 <code>null</code>，就可能出现一条不完整的学生记录。</p>
<h3>2.2 不加非空约束时</h3>
<pre><code>drop table if exists student_not_null_demo;

create table student_not_null_demo (
    id bigint,
    name varchar(20)
);

insert into student_not_null_demo (id, name)
values (1, null);

select * from student_not_null_demo;
</code></pre>
<p>结果中会出现：</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>NULL</code></td>
</tr>
</tbody>
</table>
<p>原因是 <code>name</code> 没有 <code>not null</code>，MySQL 认为 <code>null</code> 是合法值。</p>
<h3>2.3 添加非空约束</h3>
<pre><code>drop table if exists student_not_null_demo;

create table student_not_null_demo (
    id bigint,
    name varchar(20) not null
);
</code></pre>
<p>再次插入：</p>
<pre><code>insert into student_not_null_demo (id, name)
values (1, null);
</code></pre>
<p>会出现类似错误：</p>
<pre><code>ERROR 1048 (23000): Column 'name' cannot be null
</code></pre>
<p>错误产生的原因是待插入的 <code>null</code> 违反了 <code>name</code> 列的非空约束。</p>
<p>正常数据可以成功写入：</p>
<pre><code>insert into student_not_null_demo (id, name)
values (1, '张三');
</code></pre>
<h3>2.4 <code>NULL</code>、空字符串和数字 0 的区别</h3>
<p>区别：</p>
<table>
<thead>
<tr>
<th>值</th>
<th>含义</th>
<th>是否违反 <code>not null</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>null</code></td>
<td>未知、缺失或不适用</td>
<td>是</td>
</tr>
<tr>
<td><code>''</code></td>
<td>已知是一个长度为 0 的字符串</td>
<td>否</td>
</tr>
<tr>
<td><code>'   '</code></td>
<td>由空格组成的字符串</td>
<td>否</td>
</tr>
<tr>
<td><code>0</code></td>
<td>一个明确的数字</td>
<td>否</td>
</tr>
</tbody>
</table>
<p>以下语句不会违反 <code>not null</code>：</p>
<pre><code>insert into student_not_null_demo (id, name)
values (2, '');
</code></pre>
<p>但空字符串通常也不是合法姓名。<code>not null</code> 只能阻止 <code>null</code>，不能自动阻止空字符串。因此正式项目还需要后端校验，或根据业务使用更合适的 <code>check</code> 约束。</p>
<h3>2.5 查看非空约束</h3>
<pre><code>desc student_not_null_demo;
</code></pre>
<p><code>Null</code> 列的含义：</p>
<ul>
<li><code>NO</code>：不允许为 <code>null</code>。</li>
<li><code>YES</code>：允许为 <code>null</code>。</li>
</ul>
<p>更完整的检查方式：</p>
<pre><code>show create table student_not_null_demo;
</code></pre>
<p><code>desc</code> 适合快速查看字段概况，<code>show create table</code> 更适合检查真实、完整的建表定义。</p>
<h3>2.6 给已有列添加非空约束</h3>
<p>先检查是否已经存在 <code>null</code>：</p>
<pre><code>select *
from student_not_null_demo
where name is null;
</code></pre>
<p>必须先检查，原因是已有脏数据会导致添加约束失败。</p>
<p>如果业务允许把缺失姓名统一改成“待补充”，可以先清理：</p>
<pre><code>update student_not_null_demo
set name = '待补充'
where name is null;
</code></pre>
<p>然后修改字段：</p>
<pre><code>alter table student_not_null_demo
modify name varchar(20) not null;
</code></pre>
<blockquote>
<p>使用 <code>modify</code> 时要把字段类型、长度以及需要保留的字段属性重新写完整。不要只写 <code>not null</code>，否则可能意外丢失原有定义。</p>
</blockquote>
<h3>2.7 删除非空约束</h3>
<p>MySQL 没有单独的 <code>drop not null</code> 写法。要把该列重新修改为允许 <code>null</code>：</p>
<pre><code>alter table student_not_null_demo
modify name varchar(20) null;
</code></pre>
<h2>3. DEFAULT 默认值约束</h2>
<h3>3.1 作用</h3>
<p><code>default</code> 用于指定字段的默认值。</p>
<p>当 <code>insert</code> 语句没有给该字段赋值时，MySQL 会自动使用默认值。</p>
<p>例如，学生的默认年龄设为 18：</p>
<pre><code>drop table if exists student_default_demo;

create table student_default_demo (
    id bigint primary key auto_increment,
    name varchar(20) not null,
    age int default 18
);
</code></pre>
<h3>3.2 省略字段时使用默认值</h3>
<pre><code>insert into student_default_demo (name)
values ('张三');

select * from student_default_demo;
</code></pre>
<p>结果：</p>
<table>
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>age</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>张三</td>
<td>18</td>
</tr>
</tbody>
</table>
<p>原因是插入时没有指定 <code>age</code>，因此 MySQL 使用了默认值 <code>18</code>。</p>
<h3>3.3 显式写 <code>NULL</code> 不等于省略字段</h3>
<pre><code>insert into student_default_demo (name, age)
values ('李四', null);
</code></pre>
<p>由于 <code>age</code> 允许为 <code>null</code>，最终存入的是 <code>null</code>，而不是默认值 <code>18</code>。</p>
<p>两种情况要明确区分：</p>
<ul>
<li>没有给 <code>age</code> 赋值：使用默认值。</li>
<li>明确给 <code>age</code> 赋 <code>null</code>：保存 <code>null</code>，前提是该列允许为 <code>null</code>。</li>
</ul>
<p>如果要求年龄既不能缺失，又希望不填写时使用 18，应同时使用：</p>
<pre><code>age int not null default 18
</code></pre>
<p>此时：</p>
<pre><code>insert into student_default_demo (name)
values ('王五');
</code></pre>
<p>会使用默认值；而：</p>
<pre><code>insert into student_default_demo (name, age)
values ('赵六', null);
</code></pre>
<p>会违反 <code>not null</code>。</p>
<h3>3.4 使用 <code>DEFAULT</code> 关键字</h3>
<p>除了省略字段，也可以显式要求使用默认值：</p>
<pre><code>insert into student_default_demo (name, age)
values ('钱七', default);
</code></pre>
<p>更新时也可以恢复为默认值：</p>
<pre><code>update student_default_demo
set age = default
where name = '钱七';
</code></pre>
<h3>3.5 常用的时间默认值</h3>
<pre><code>create table article_demo (
    id bigint primary key auto_increment,
    title varchar(100) not null,
    create_time datetime not null default current_timestamp,
    update_time datetime not null
        default current_timestamp
        on update current_timestamp
);
</code></pre>
<p>含义：</p>
<ul>
<li>新增记录时，<code>create_time</code> 和 <code>update_time</code> 自动写入当前时间。</li>
<li>更新记录时，<code>update_time</code> 自动变为本次更新时间。</li>
</ul>
<p>这适合记录数据创建时间与最后修改时间。</p>
<h3>3.6 修改和删除默认值</h3>
<p>添加或修改默认值：</p>
<pre><code>alter table student_default_demo
alter column age set default 18;
</code></pre>
<p>删除默认值：</p>
<pre><code>alter table student_default_demo
alter column age drop default;
</code></pre>
<p>删除默认值不会删除已有数据，只会影响之后没有指定该字段的新写入。</p>
<h3>3.7 <code>DEFAULT</code> 不能替代业务校验</h3>
<p>默认值只负责“未填写时用什么”，不负责“填写的值是否合理”。</p>
<p>例如：</p>
<pre><code>age int default 18
</code></pre>
<p>仍然允许写入 <code>-100</code>。如果年龄必须不小于 16，还应增加：</p>
<pre><code>constraint ck_student_age check (age &gt;= 16)
</code></pre>
<hr />
<h2>4. UNIQUE 唯一约束</h2>
<h3>4.1 作用</h3>
<p><code>unique</code> 保证某一列或某一组列的值不重复。</p>
<p>典型场景包括：</p>
<ul>
<li>学号不能重复。</li>
<li>用户名不能重复。</li>
<li>手机号不能重复。</li>
<li>同一个学生不能重复选择同一门课程。</li>
</ul>
<h3>4.2 单列唯一约束</h3>
<pre><code>drop table if exists student_unique_demo;

create table student_unique_demo (
    id bigint primary key auto_increment,
    name varchar(20) not null,
    sno varchar(20),

    constraint uk_student_unique_demo_sno unique (sno)
);
</code></pre>
<p>正常插入：</p>
<pre><code>insert into student_unique_demo (name, sno)
values ('张三', '20260001');
</code></pre>
<p>重复插入相同学号：</p>
<pre><code>insert into student_unique_demo (name, sno)
values ('李四', '20260001');
</code></pre>
<p>会出现类似错误：</p>
<pre><code>ERROR 1062 (23000): Duplicate entry '20260001'
for key 'student_unique_demo.uk_student_unique_demo_sno'
</code></pre>
<p>错误原因是学号 <code>20260001</code> 已经存在，违反了唯一约束。</p>
<h3>4.3 MySQL 的唯一约束允许多个 <code>NULL</code></h3>
<p>如果唯一列本身允许 <code>null</code>，MySQL 允许多行都保存 <code>null</code>：</p>
<pre><code>insert into student_unique_demo (name, sno)
values ('王五', null);

insert into student_unique_demo (name, sno)
values ('赵六', null);
</code></pre>
<p>两条语句都可以成功。</p>
<p>原因是 SQL 中的 <code>null</code> 表示未知，两个未知值不会被直接判定为相等。</p>
<p>如果业务要求“学号必须填写并且不能重复”，应该组合使用：</p>
<pre><code>sno varchar(20) not null,
constraint uk_student_unique_demo_sno unique (sno)
</code></pre>
<h3>4.4 联合唯一约束</h3>
<p>联合唯一约束同时检查多个字段的组合。</p>
<p>例如，规定同一个班级内学生姓名不能重复：</p>
<pre><code>create table class_student_name_demo (
    id bigint primary key auto_increment,
    class_id bigint not null,
    name varchar(20) not null,

    constraint uk_class_student_name
        unique (class_id, name)
);
</code></pre>
<p>以下两行可以同时存在：</p>
<table>
<thead>
<tr>
<th>class_id</th>
<th>name</th>
<th>是否允许</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>张三</td>
<td>允许</td>
</tr>
<tr>
<td>2</td>
<td>张三</td>
<td>允许</td>
</tr>
</tbody>
</table>
<p>因为组合 <code>(1, '张三')</code> 与 <code>(2, '张三')</code> 不同。</p>
<p>但再次插入 <code>(1, '张三')</code> 会冲突。</p>
<blockquote>
<p>联合唯一约束限制的是“字段组合不能重复”，不是要求每个字段各自都唯一。</p>
</blockquote>
<h3>4.5 唯一约束与唯一索引</h3>
<p>下面两种 MySQL 写法都能限制 <code>username</code> 不重复：</p>
<pre><code>constraint uk_index_user_username unique (username)
</code></pre>
<pre><code>unique index uk_index_user_username (username)
</code></pre>
<p>其中：</p>
<ul>
<li><code>uk_index_user_username</code> 是人为指定的名称。</li>
<li><code>uk</code> 常用来表示 unique key。</li>
<li><code>index_user</code> 可以表示表名。</li>
<li><code>username</code> 表示被约束的字段。</li>
</ul>
<p>从概念上看：</p>
<ul>
<li>唯一约束表达的是数据规则：“值不能重复”。</li>
<li>唯一索引是 MySQL 实现并快速检查这条规则的数据结构。</li>
</ul>
<p>因此二者密切相关，但“约束”和“索引”仍然不是同一个概念。普通索引只用于提高查询效率，并不会禁止重复值。</p>
<h3>4.6 添加和删除唯一约束</h3>
<p>为了让下面的 <code>alter table</code> 可以直接练习，先创建一张尚未设置唯一约束的表：</p>
<pre><code>drop table if exists student_unique_alter_demo;

create table student_unique_alter_demo (
    id bigint primary key auto_increment,
    sno varchar(20)
);
</code></pre>
<p>添加前先检查重复数据：</p>
<pre><code>select sno, count(*) as duplicate_count
from student_unique_alter_demo
where sno is not null
group by sno
having count(*) &gt; 1;
</code></pre>
<p>必须先检查，原因是只要存在一组重复数据，添加唯一约束就会失败。</p>
<p>添加：</p>
<pre><code>alter table student_unique_alter_demo
add constraint uk_student_unique_alter_sno unique (sno);
</code></pre>
<p>在 MySQL 中删除唯一约束通常使用索引名称：</p>
<pre><code>alter table student_unique_alter_demo
drop index uk_student_unique_alter_sno;
</code></pre>
<hr />
<h2>5. PRIMARY KEY 主键约束</h2>
<h3>5.1 作用</h3>
<p>主键用于唯一标识表中的每一行数据。</p>
<p>主键具有三个核心特点：</p>
<ol>
<li>主键值不能重复。</li>
<li>主键列不能为 <code>null</code>。</li>
<li>一张表只能有一个主键约束，但这个主键可以由一个或多个字段组成。</li>
</ol>
<p>注意“一张表只能有一个主键”不等于“主键只能有一列”。多列可以共同组成一个复合主键。</p>
<h3>5.2 常用的自增主键</h3>
<pre><code>drop table if exists student_primary_demo;

create table student_primary_demo (
    id bigint primary key auto_increment,
    name varchar(20) not null
);
</code></pre>
<p>插入时可以省略 <code>id</code>：</p>
<pre><code>insert into student_primary_demo (name)
values ('张三'), ('李四');
</code></pre>
<p>MySQL 会生成主键值。</p>
<p><code>auto_increment</code> 的作用是让数据库生成递增编号，减少应用程序自行计算主键时产生冲突的风险。</p>
<h3>5.3 常用 <code>BIGINT</code></h3>
<p>在业务系统中，主键常用：</p>
<pre><code>id bigint primary key auto_increment
</code></pre>
<p>原因包括：</p>
<ul>
<li><code>bigint</code> 的可用范围大，数据量增长后不容易耗尽。</li>
<li>整数比较和索引处理通常比较直接。</li>
<li>Java 中可以使用 <code>Long</code> 对应。</li>
</ul>
<p>但“所有项目必须使用自增 <code>bigint</code>”并不是绝对规则。分布式系统也可能使用雪花 ID、UUID 或其他全局 ID。当前学习阶段先掌握自增整数主键最合适。</p>
<h3>5.4 自增主键不保证连续</h3>
<p>下面这些情况都可能让 ID 出现空缺：</p>
<ul>
<li>插入失败前已经申请了自增值。</li>
<li>事务回滚。</li>
<li>删除了一行数据。</li>
<li>并发插入。</li>
<li>手工插入了更大的主键值。</li>
</ul>
<p>因此：</p>
<ul>
<li>主键只负责唯一标识。</li>
<li>不要把“必须连续”当成主键规则。</li>
<li>不要用主键是否连续判断数据有没有被删除。</li>
</ul>
<p>例如出现 <code>1、2、4、5</code> 是正常现象，缺少 <code>3</code> 不代表数据库损坏。</p>
<h3>5.5 <code>PRIMARY KEY</code> 与 <code>NOT NULL + UNIQUE</code> 的区别</h3>
<p>从数据限制上看，单列主键确实同时具有“非空”和“唯一”的效果，但下面两种定义仍然不是完全相同的概念：</p>
<pre><code>id bigint primary key
</code></pre>
<pre><code>id bigint not null unique
</code></pre>
<p>主要区别：</p>
<table>
<thead>
<tr>
<th>对比点</th>
<th><code>primary key</code></th>
<th><code>not null unique</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>语义</td>
<td>表中每行的主要身份标识</td>
<td>一个不能为空且不能重复的候选字段</td>
</tr>
<tr>
<td>每张表数量</td>
<td>只能有一个主键约束</td>
<td>可以有多个唯一约束</td>
</tr>
<tr>
<td>InnoDB 组织方式</td>
<td>主键通常作为聚簇索引键</td>
<td>通常属于二级唯一索引</td>
</tr>
<tr>
<td>外键引用</td>
<td>最常用、最清晰</td>
<td>也可作为候选键被引用，但设计语义不同</td>
</tr>
</tbody>
</table>
<h4>为什么 <code>DESC</code> 有时会把 <code>NOT NULL UNIQUE</code> 显示为 <code>PRI</code></h4>
<p>当表没有显式主键时，MySQL 的某些元数据展示会把第一个“非空唯一索引”作为主键候选显示。不能因此断定建表语句中真的声明了 <code>primary key</code>。</p>
<p>最可靠的检查方法是：</p>
<pre><code>show create table student_primary_demo;
</code></pre>
<p>如果输出中明确存在：</p>
<pre><code>primary key (`id`)
</code></pre>
<p>才能确认它是显式定义的主键。</p>
<blockquote>
<p>不要仅凭 <code>desc</code> 的 <code>Key</code> 列判断所有约束。<code>desc</code> 是摘要，<code>show create table</code> 才能还原更完整的定义。</p>
</blockquote>
<h3>5.6 复合主键</h3>
<p>成绩表天然适合使用“学生编号 + 课程编号”组成主键：</p>
<pre><code>create table score_primary_demo (
    student_id bigint not null,
    course_id bigint not null,
    score decimal(5, 2),

    constraint pk_score_primary_demo
        primary key (student_id, course_id)
);
</code></pre>
<p>以下数据可以同时存在：</p>
<table>
<thead>
<tr>
<th>student_id</th>
<th>course_id</th>
<th>score</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>1</td>
<td>90.00</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>85.00</td>
</tr>
<tr>
<td>2</td>
<td>1</td>
<td>88.00</td>
</tr>
</tbody>
</table>
<p>但 <code>(student_id, course_id) = (1, 1)</code> 不能再次出现。</p>
<p>它表达的业务规则是：</p>
<blockquote>
<p>同一名学生对同一门课程只能有一条成绩记录。</p>
</blockquote>
<h3>5.7 复合主键与联合唯一约束</h3>
<p>二者都能限制字段组合不重复，但角色不同：</p>
<pre><code>primary key (student_id, course_id)
</code></pre>
<p>表示这两个字段的组合就是该行的主要身份标识。</p>
<pre><code>id bigint primary key auto_increment,
unique (student_id, course_id)
</code></pre>
<p>表示：</p>
<ul>
<li><code>id</code> 是该行的主身份标识。</li>
<li><code>(student_id, course_id)</code> 是额外的业务唯一规则。</li>
</ul>
<p>两种设计都可能合理：</p>
<table>
<thead>
<tr>
<th>设计</th>
<th>优点</th>
<th>适合场景</th>
</tr>
</thead>
<tbody>
<tr>
<td>复合主键 <code>(student_id, course_id)</code></td>
<td>直接表达业务身份，不需要额外 <code>id</code></td>
<td>纯关联表、结构较简单</td>
</tr>
<tr>
<td>自增主键 <code>id</code> + 联合唯一约束</td>
<td>其他表引用方便，ORM 使用较简单</td>
<td>关联记录还会被其他表引用，或项目统一使用单列主键</td>
</tr>
</tbody>
</table>
<h3>5.8 添加和删除主键</h3>
<p>为了单独练习主键变更，先使用一张不带 <code>auto_increment</code> 的测试表：</p>
<pre><code>drop table if exists student_primary_alter_demo;

create table student_primary_alter_demo (
    id bigint not null,
    name varchar(20) not null
);
</code></pre>
<p>给已有字段添加主键前，要保证该字段没有 <code>null</code> 且没有重复值：</p>
<pre><code>select id, count(*) as duplicate_count
from student_primary_alter_demo
group by id
having count(*) &gt; 1;
</code></pre>
<p>添加主键：</p>
<pre><code>alter table student_primary_alter_demo
add primary key (id);
</code></pre>
<p>删除主键：</p>
<pre><code>alter table student_primary_alter_demo
drop primary key;
</code></pre>
<p>生产环境删除主键属于高风险结构变更，原因是它可能影响外键、索引组织、查询性能及其他依赖。实际项目中应先检查依赖并做好变更方案。</p>
<p>如果主键列还带有 <code>auto_increment</code>，通常不能直接删除它唯一可用的键；应先设计新的键方案并处理 <code>auto_increment</code> 属性。不要把上面的简单测试语句直接用于生产表。</p>
<hr />
<h2>6. FOREIGN KEY 外键约束</h2>
<h3>6.1 外键解决什么问题作用</h3>
<p>外键用于保证表与表之间引用关系的完整性。</p>
<p>例如：</p>
<ul>
<li>班级表保存班级。</li>
<li>学生表通过 <code>class_id</code> 保存学生所属班级。</li>
</ul>
<p>如果学生表中出现 <code>class_id = 100</code>，但班级表根本没有编号 100 的班级，这条数据就失去了真实指向。外键可以阻止这种无效引用。</p>
<h3>6.2 主表、从表、主键和外键</h3>
<p><img src="./constraint1.png" alt="" /></p>
<p>以班级与学生为例：</p>
<table>
<thead>
<tr>
<th>术语</th>
<th>对应对象</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>主表、父表、被引用表</td>
<td><code>class_design2</code></td>
<td>保存被其他表引用的班级</td>
</tr>
<tr>
<td>从表、子表、引用表</td>
<td><code>student_design2</code></td>
<td>保存外键并引用班级</td>
</tr>
<tr>
<td>被引用键</td>
<td><code>class_design2.id</code></td>
<td>通常是主键或唯一键</td>
</tr>
<tr>
<td>外键列</td>
<td><code>student_design2.class_id</code></td>
<td>保存主表中已经存在的编号</td>
</tr>
</tbody>
</table>
<blockquote>
<p>“主表”和“从表”是相对于某一条外键关系而言的。同一张表在不同关系中可能既是主表又是从表。</p>
</blockquote>
<h3>6.3 创建主表</h3>
<p>先创建班级表：</p>
<pre><code>drop table if exists student_fk_demo;
drop table if exists class_fk_demo;

create table class_fk_demo (
    id bigint primary key auto_increment,
    name varchar(30) not null,

    constraint uk_class_fk_demo_name unique (name)
) engine = innodb;
</code></pre>
<p>插入班级：</p>
<pre><code>insert into class_fk_demo (name)
values ('Java001班'), ('MySQL001班');
</code></pre>
<p>先创建主表的原因是从表在创建外键时必须能够找到被引用的表与字段。</p>
<h3>6.4 创建从表并定义外键</h3>
<pre><code>drop table if exists student_fk_demo;

create table student_fk_demo (
    id bigint primary key auto_increment,
    sno varchar(20) not null,
    name varchar(20) not null,
    class_id bigint,

    constraint uk_student_fk_demo_sno
        unique (sno),

    constraint fk_student_fk_demo_class
        foreign key (class_id)
        references class_fk_demo (id)
) engine = innodb;
</code></pre>
<p>外键核心语法：</p>
<pre><code>constraint 外键名称
foreign key (从表字段)
references 主表名称 (主表被引用字段)
</code></pre>
<p>推荐主动给外键命名。否则 MySQL 会生成类似 <code>student_fk_demo_ibfk_1</code> 的名称，出现错误或删除外键时不够直观。</p>
<h3>6.5 外键写入</h3>
<h4>情况一：主表中存在该编号</h4>
<pre><code>insert into student_fk_demo (sno, name, class_id)
values ('20260001', '张三', 1);
</code></pre>
<p>只要班级表存在 <code>id = 1</code>，就可以成功。</p>
<h4>情况二：主表中不存在该编号</h4>
<pre><code>insert into student_fk_demo (sno, name, class_id)
values ('20260002', '李四', 100);
</code></pre>
<p>会出现类似错误：</p>
<pre><code>ERROR 1452 (23000): Cannot add or update a child row:
a foreign key constraint fails
</code></pre>
<p>错误原因是 <code>class_id = 100</code> 在主表中没有对应记录。</p>
<h4>情况三：外键值为 <code>NULL</code></h4>
<pre><code>insert into student_fk_demo (sno, name, class_id)
values ('20260003', '王五', null);
</code></pre>
<p>这条语句可以成功，因为 <code>class_id</code> 没有 <code>not null</code>。</p>
<p>此时 <code>null</code> 可以表达“暂未分配班级”。如果业务要求每名学生必须属于一个班级，应把字段定义为：</p>
<pre><code>class_id bigint not null
</code></pre>
<blockquote>
<p>外键约束检查的是“非空外键值是否存在于主表”。是否允许不填写，还要由 <code>not null</code> 决定。</p>
</blockquote>
<h3>6.6 默认情况下为什么不能删除被引用的主表记录</h3>
<p>如果 <code>Java001班</code> 已经有学生，执行：</p>
<pre><code>delete from class_fk_demo
where id = 1;
</code></pre>
<p>通常会出现：</p>
<pre><code>ERROR 1451 (23000): Cannot delete or update a parent row:
a foreign key constraint fails
</code></pre>
<p>MySQL 拒绝删除，原因是删除班级后，学生表中的 <code>class_id = 1</code> 会变成无效引用。</p>
<p>如果一个班级没有任何学生引用，则可以正常删除。</p>
<h3>6.7 <code>ON DELETE</code> 与 <code>ON UPDATE</code></h3>
<p>外键可以指定主表记录被删除或被更新时，从表如何处理。</p>
<p>基本语法：</p>
<pre><code>constraint fk_student_fk_demo_class
foreign key (class_id)
references class_fk_demo (id)
on delete restrict
on update cascade
</code></pre>
<p>常用动作：</p>
<table>
<thead>
<tr>
<th>动作</th>
<th>主表被删除或更新时</th>
<th>适用思路</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>restrict</code></td>
<td>如果存在从表引用，就拒绝操作</td>
<td>默认且最保守，适合重要业务数据</td>
</tr>
<tr>
<td><code>no action</code></td>
<td>InnoDB 中与 <code>restrict</code> 基本等价</td>
<td>SQL 标准写法</td>
</tr>
<tr>
<td><code>cascade</code></td>
<td>自动删除或更新对应从表记录</td>
<td>强依赖关系，如订单与纯明细</td>
</tr>
<tr>
<td><code>set null</code></td>
<td>把从表外键值设为 <code>null</code></td>
<td>关系可以解除，且外键列允许 <code>null</code></td>
</tr>
</tbody>
</table>
<p><code>set default</code> 虽然会被 MySQL 语法解析器识别，但 InnoDB 不接受这种引用动作，不应把它作为普通可用方案。</p>
<h4><code>ON DELETE RESTRICT</code></h4>
<pre><code>on delete restrict
</code></pre>
<p>存在学生时禁止删除班级。省略 <code>on delete</code> 时，InnoDB 的默认行为也相当于限制删除。</p>
<h4><code>ON DELETE CASCADE</code></h4>
<pre><code>on delete cascade
</code></pre>
<p>删除班级时，属于该班级的学生也会自动删除。</p>
<p>这种操作影响范围可能很大。除非从表数据没有独立保留价值，否则不要因为“删除方便”就随意使用级联删除。</p>
<h4><code>ON DELETE SET NULL</code></h4>
<pre><code>on delete set null
</code></pre>
<p>删除班级时，学生仍然保留，但其 <code>class_id</code> 自动变为 <code>null</code>。</p>
<p>要使用它，外键字段必须允许 <code>null</code>：</p>
<pre><code>class_id bigint
</code></pre>
<p>如果写成 <code>class_id bigint not null</code>，就与 <code>set null</code> 的行为矛盾。</p>
<h4><code>ON UPDATE CASCADE</code></h4>
<pre><code>on update cascade
</code></pre>
<p>如果主表被引用键发生修改，从表外键会同步修改。</p>
<p>主键通常不应频繁改变，因此它更多是完整性保障，而不是日常更新手段。</p>
<h3>6.8 如何选择外键动作</h3>
<p>可以按下面的顺序思考：
<img src="./constraint2.png" alt="" /></p>
<p>初学和普通业务设计中，优先使用 <code>restrict</code>，等业务语义非常明确时再选择 <code>cascade</code> 或 <code>set null</code>。</p>
<h3>6.9 添加和删除外键</h3>
<p>前面的建表语句已经创建了外键。为了演示修改流程，先删除原外键：</p>
<pre><code>alter table student_fk_demo
drop foreign key fk_student_fk_demo_class;
</code></pre>
<p>重新添加前要检查是否已经存在孤立数据：</p>
<pre><code>select s.*
from student_fk_demo as s
left join class_fk_demo as c
    on s.class_id = c.id
where s.class_id is not null
  and c.id is null;
</code></pre>
<p>这条查询的逻辑是：</p>
<ol>
<li>使用左连接保留所有学生。</li>
<li>尝试根据 <code>class_id</code> 找对应班级。</li>
<li>外键不为空但 <code>c.id</code> 仍为空，说明引用的班级不存在。</li>
</ol>
<p>存在这种数据时，应先清理或补齐班级，再添加外键。</p>
<p>确认查询没有返回孤立数据后，重新添加：</p>
<pre><code>alter table student_fk_demo
add constraint fk_student_fk_demo_class
foreign key (class_id)
references class_fk_demo (id)
on delete restrict
on update cascade;
</code></pre>
<p>如果后续确实不再需要这条关系，可以再次删除：</p>
<pre><code>alter table student_fk_demo
drop foreign key fk_student_fk_demo_class;
</code></pre>
<p>注意：删除外键约束后，外键列上的索引可能仍然存在。是否继续保留要根据查询需求决定，可以使用下面的语句检查：</p>
<pre><code>show index from student_fk_demo;
</code></pre>
<h3>6.10 创建外键的必要条件</h3>
<p>常见要求包括：</p>
<ol>
<li>主表和从表都应使用支持外键的存储引擎，通常是 InnoDB。</li>
<li>对应字段的数据类型要匹配。</li>
<li>整数类型的长度与是否 <code>unsigned</code> 要一致。</li>
<li>字符串外键的字符集与排序规则要一致。</li>
<li>被引用字段要有合适的索引；正式关系设计中通常引用主键或唯一键。</li>
<li>现有数据必须已经满足引用关系。</li>
<li>创建从表前，主表必须存在。</li>
</ol>
<p>例如，下面两列并不完全匹配：</p>
<pre><code>-- 主表
id bigint unsigned

-- 从表
class_id bigint
</code></pre>
<p>一个是无符号 <code>bigint unsigned</code>，另一个是有符号 <code>bigint</code>，创建外键可能失败。应把两边统一。</p>
<h3>6.11 外键常伴随索引</h3>
<p>MySQL 检查外键时需要快速完成两类查询：</p>
<ul>
<li>写入从表时，查找主表中是否存在被引用值。</li>
<li>删除或更新主表时，查找从表中是否存在引用。</li>
</ul>
<p>如果没有索引，每次检查都可能扫描大量数据。因此 MySQL 要求外键两端具有合适的索引；从表没有可用索引时，InnoDB 通常会自动创建。</p>
<p>但是必须区分：</p>
<ul>
<li>外键是数据关系规则。</li>
<li>索引是帮助查找数据的数据结构。</li>
<li>索引可以为外键检查服务，但索引本身不会保证引用关系。</li>
</ul>
<p><code>desc student_fk_demo</code> 中 <code>class_id</code> 的 <code>Key</code> 可能显示 <code>MUL</code>。<code>MUL</code> 只说明该列位于一个可重复值的索引中，不能单凭它证明该列一定有外键。</p>
<p>检查外键应该使用：</p>
<pre><code>show create table student_fk_demo;
</code></pre>
<h3>6.12 外键不会自动完成连接查询</h3>
<p>定义外键后，查询学生及班级仍然需要显式连接：</p>
<pre><code>select
    s.id,
    s.sno,
    s.name as student_name,
    c.name as class_name
from student_fk_demo as s
left join class_fk_demo as c
    on s.class_id = c.id;
</code></pre>
<p>外键负责保证关系正确，<code>join</code> 负责按照关系取出数据，二者作用不同。</p>
<p>外键负责保证关系正确，<code>join</code> 负责按照关系取出数据，二者作用不同。</p>
<h2>7. CHECK 检查约束</h2>
<h3>7.1 作用与版本</h3>
<p><code>check</code> 用于限制列值范围，或限制同一行中多个字段之间的关系。</p>
<p>MySQL 8.0.16 之前虽然能解析 <code>check</code> 语法，但通常不会真正执行检查。从 MySQL 8.0.16 开始才正式执行核心 <code>check</code> 约束。</p>
<h3>7.2 限制年龄与性别</h3>
<pre><code>drop table if exists student_check_demo;

create table student_check_demo (
    id bigint primary key auto_increment,
    name varchar(20) not null,
    age int not null default 18,
    gender char(1),

    constraint ck_student_check_demo_age
        check (age &gt;= 16),

    constraint ck_student_check_demo_gender
        check (gender in ('男', '女') or gender is null)
);
</code></pre>
<p>正常数据：</p>
<pre><code>insert into student_check_demo (name, age, gender)
values ('张三', 17, '男');
</code></pre>
<p>年龄不合法：</p>
<pre><code>insert into student_check_demo (name, age, gender)
values ('李四', 15, '女');
</code></pre>
<p>会出现类似错误：</p>
<pre><code>ERROR 3819 (HY000): Check constraint
'ck_student_check_demo_age' is violated.
</code></pre>
<p>性别不合法：</p>
<pre><code>insert into student_check_demo (name, age, gender)
values ('王五', 18, '未知');
</code></pre>
<p>会违反性别检查约束。</p>
<p>使用：</p>
<pre><code>gender in ('男', '女')
</code></pre>
<p>比下面的写法更容易扩展和阅读：</p>
<pre><code>gender = '男' or gender = '女'
</code></pre>
<h3>7.3 <code>CHECK</code> 与 <code>NULL</code> 的关系</h3>
<p><code>check</code> 表达式的结果为：</p>
<ul>
<li><code>true</code>：通过。</li>
<li><code>false</code>：拒绝。</li>
<li><code>unknown</code>：通常也通过；涉及 <code>null</code> 的比较往往得到 <code>unknown</code>。</li>
</ul>
<p>例如：</p>
<pre><code>age int check (age &gt;= 16)
</code></pre>
<p>如果 <code>age = null</code>，表达式 <code>null &gt;= 16</code> 不是 <code>false</code>，而是 <code>unknown</code>，因此不能只靠这条 <code>check</code> 阻止 <code>null</code>。</p>
<p>如果要求年龄必须填写且不小于 16，应写：</p>
<pre><code>age int not null,
constraint ck_student_age check (age &gt;= 16)
</code></pre>
<p>其中：</p>
<ul>
<li><code>not null</code> 负责“必须填写”。</li>
<li><code>check</code> 负责“填写后必须在合法范围内”。</li>
</ul>
<h3>7.4 限制数值范围</h3>
<p>成绩应在 0 到 100 之间：</p>
<pre><code>score decimal(5, 2),
constraint ck_score_range
    check (score between 0 and 100)
</code></pre>
<p><code>between 0 and 100</code> 包含边界 0 与 100。</p>
<p>如果成绩必须存在：</p>
<pre><code>score decimal(5, 2) not null,
constraint ck_score_range
    check (score between 0 and 100)
</code></pre>
<h3>7.5 检查多个字段之间的关系</h3>
<pre><code>create table activity_check_demo (
    id bigint primary key auto_increment,
    activity_name varchar(50) not null,
    start_time datetime not null,
    end_time datetime not null,

    constraint ck_activity_time
        check (end_time &gt; start_time)
);
</code></pre>
<p>这里必须使用表级约束，原因是规则同时引用了 <code>start_time</code> 和 <code>end_time</code>。</p>
<p>它保证结束时间一定晚于开始时间。</p>
<h3>7.6 添加、暂停执行和删除检查约束</h3>
<p>前面的建表语句已经定义了年龄检查。为了演示完整管理流程，先删除原约束：</p>
<pre><code>alter table student_check_demo
drop check ck_student_check_demo_age;
</code></pre>
<p>重新添加：</p>
<pre><code>alter table student_check_demo
add constraint ck_student_check_demo_age
check (age &gt;= 16);
</code></pre>
<p>暂停执行但保留定义：</p>
<pre><code>alter table student_check_demo
alter check ck_student_check_demo_age not enforced;
</code></pre>
<p>重新执行：</p>
<pre><code>alter table student_check_demo
alter check ck_student_check_demo_age enforced;
</code></pre>
<p>删除：</p>
<pre><code>alter table student_check_demo
drop check ck_student_check_demo_age;
</code></pre>
<p><code>not enforced</code> 更适合受控迁移，不适合作为长期绕过规则的手段。</p>
<h3>7.7 <code>CHECK</code> 的边界</h3>
<p>适合：</p>
<ul>
<li>数值范围。</li>
<li>枚举值范围。</li>
<li>同一行字段之间的大小或逻辑关系。</li>
<li>简单、稳定、必须由所有写入入口遵守的规则。</li>
</ul>
<p>不适合：</p>
<ul>
<li>需要查询其他表才能判断的复杂规则。</li>
<li>经常变化的业务流程。</li>
<li>需要调用外部服务的规则。</li>
<li>复杂权限与状态机。</li>
</ul>
<p>这些规则通常应由后端业务代码、事务和其他数据库机制共同完成。</p>
<hr />
<h2>8. 使用 ALTER TABLE 管理已有表的约束</h2>
<h3>8.1 常用操作</h3>
<p>下面各段是独立语法模板，不是一组应当从头到尾连续执行的脚本。使用其中一项前，要先确认当前表是否已经存在同类约束。</p>
<h4>添加非空约束</h4>
<pre><code>alter table student_design2
modify name varchar(20) not null;
</code></pre>
<h4>删除非空约束</h4>
<pre><code>alter table student_design2
modify name varchar(20) null;
</code></pre>
<h4>添加默认值</h4>
<pre><code>alter table student_design2
alter column age set default 18;
</code></pre>
<h4>删除默认值</h4>
<pre><code>alter table student_design2
alter column age drop default;
</code></pre>
<h4>添加唯一约束</h4>
<pre><code>alter table student_design2
add constraint uk_student_design2_sno unique (sno);
</code></pre>
<h4>删除唯一约束</h4>
<pre><code>alter table student_design2
drop index uk_student_design2_sno;
</code></pre>
<h4>添加主键</h4>
<pre><code>alter table student_design2
add primary key (id);
</code></pre>
<h4>删除主键</h4>
<pre><code>alter table student_design2
drop primary key;
</code></pre>
<p>如果主键列带有 <code>auto_increment</code> 或正在被外键引用，不能直接照搬这条语句，必须先处理相关属性与依赖。</p>
<h4>添加外键</h4>
<pre><code>alter table student_design2
add constraint fk_student_design2_class
foreign key (class_id)
references class_design2 (id);
</code></pre>
<h4>删除外键</h4>
<pre><code>alter table student_design2
drop foreign key fk_student_design2_class;
</code></pre>
<h4>添加检查约束</h4>
<pre><code>alter table student_design2
add constraint ck_student_design2_age
check (age between 16 and 100);
</code></pre>
<h4>删除检查约束</h4>
<pre><code>alter table student_design2
drop check ck_student_design2_age;
</code></pre>
<h2>9. 约束、索引和数据类型的关系</h2>
<h3>9.1 三者解决的问题不同</h3>
<table>
<thead>
<tr>
<th>对象</th>
<th>主要问题</th>
<th>示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据类型</td>
<td>这一列存储哪一类数据</td>
<td><code>int</code>、<code>varchar(20)</code>、<code>datetime</code></td>
</tr>
<tr>
<td>约束</td>
<td>哪些数据允许进入数据库</td>
<td><code>not null</code>、<code>unique</code>、<code>foreign key</code></td>
</tr>
<tr>
<td>索引</td>
<td>怎样更快定位数据</td>
<td>普通索引、唯一索引、主键索引</td>
</tr>
</tbody>
</table>
<p>例如：</p>
<pre><code>sno varchar(20) not null unique
</code></pre>
<p>可以拆成三层理解：</p>
<ul>
<li><code>varchar(20)</code>：数据类型，限制为最多 20 个字符的变长字符串。</li>
<li><code>not null</code>：非空规则。</li>
<li><code>unique</code>：不重复规则，并由 MySQL 建立相应的唯一索引来实现高效检查。</li>
</ul>
<h3>9.2 约束不是索引</h3>
<h4>约束关注数据正确性</h4>
<pre><code>constraint fk_student_class
foreign key (class_id)
references class_design2 (id)
</code></pre>
<p>它要求 <code>class_id</code> 必须引用真实班级。</p>
<h4>索引关注数据查找效率</h4>
<pre><code>index idx_student_name (name)
</code></pre>
<p>它可以提高按姓名查找的效率，但允许姓名重复，也不检查班级关系。</p>
<h3>9.3 如何检查表中到底有什么</h3>
<h4>快速查看字段</h4>
<pre><code>desc student_design2;
</code></pre>
<h4>查看完整建表定义</h4>
<pre><code>show create table student_design2;
</code></pre>
<h4>查看索引</h4>
<pre><code>show index from student_design2;
</code></pre>
<h4>查看当前数据库的约束</h4>
<pre><code>select
    constraint_name,
    constraint_type,
    table_name
from information_schema.table_constraints
where table_schema = database()
  and table_name = 'student_design2';
</code></pre>
<p>推荐理解为：</p>
<ul>
<li><code>desc</code>：看摘要。</li>
<li><code>show create table</code>：看完整定义。</li>
<li><code>show index</code>：看索引细节。</li>
<li><code>information_schema</code>：批量查询数据库元数据。</li>
</ul>
<hr />
<h2>10. 数据迁移与表复制中的约束问题</h2>
<h3>10.1 <code>CREATE TABLE ... LIKE</code></h3>
<p>复制表结构：</p>
<pre><code>drop table if exists student_backup;

create table student_backup
like student_design2;
</code></pre>
<p>再复制数据：</p>
<pre><code>insert into student_backup
select *
from student_design2;
</code></pre>
<p>这种方式把“创建结构”和“复制数据”分成两步，容易检查和控制。</p>
<h3>10.2 <code>LIKE</code> 会复制什么</h3>
<p>在 MySQL 8.0 中，<code>create table ... like</code> 会复制大量表结构信息，例如：</p>
<ul>
<li>字段名与数据类型。</li>
<li><code>not null</code>。</li>
<li>默认值。</li>
<li>主键和索引，因此唯一约束对应的唯一索引也会复制。</li>
<li><code>check</code> 约束，但 MySQL 会为副本重新生成检查约束名称。</li>
</ul>
<p>它不会复制：</p>
<ul>
<li>表中的数据。</li>
<li>外键定义。</li>
</ul>
<p>所以一定要比较：</p>
<pre><code>show create table student_design2;
show create table student_backup;
</code></pre>
<p>不能只看字段表面相同，就判断两个表的完整结构完全一致。</p>
<h3>10.3 如何给副本重新添加外键</h3>
<p>如果副本确实需要继续引用原班级表，可以执行：</p>
<pre><code>alter table student_backup
add constraint fk_student_backup_class
foreign key (class_id)
references class_design2 (id)
on delete restrict
on update cascade;
</code></pre>
<p>外键名称使用 <code>fk_student_backup_class</code>，而不是复用原表名称，原因是名称应明确对应当前关系，也可避免命名冲突。</p>
<p>但备份表是否应该保留外键要看目的：</p>
<ul>
<li>如果只是保存历史快照，保留外键可能导致主表数据变化时备份受影响。</li>
<li>如果副本仍参与正常业务，保留外键更有利于一致性。</li>
</ul>
<p>不能机械地认为“结构越一样越好”，应先明确副本用途。</p>
<h3>10.4 <code>CREATE TABLE ... SELECT</code></h3>
<pre><code>create table student_backup2 as
select *
from student_design2;
</code></pre>
<p>这种方式可以一次完成“建表 + 复制查询结果”，但通常不会完整保留原表的主键、唯一索引、外键和其他完整定义。</p>
<p>因此：</p>
<ul>
<li>临时分析表可以使用。</li>
<li>需要精确保留表设计时，不应只依赖这种方式。</li>
<li>正式复制后必须用 <code>show create table</code> 检查。</li>
</ul>
<p>推荐的正式学习写法仍然是：</p>
<pre><code>create table student_backup like student_design2;

insert into student_backup
select *
from student_design2;
</code></pre>
<p>然后按业务需要单独恢复外键。</p>
<h2>11. 主键或唯一键冲突时的写入方式</h2>
<p>原资料把这部分放在主键章节中，因为这些语句都依赖主键或唯一键发现冲突。</p>
<h3>11.1 普通 <code>INSERT</code></h3>
<pre><code>insert into student_design2
    (sno, name, age, class_id)
values
    ('20260001', '张三', 18, 1);
</code></pre>
<p>如果 <code>sno = '20260001'</code> 已存在，普通插入会报 1062 重复键错误。</p>
<h3>11.2 <code>INSERT ... ON DUPLICATE KEY UPDATE</code></h3>
<p>它的含义是：</p>
<ul>
<li>没有主键或唯一键冲突：插入新行。</li>
<li>发生冲突：更新已经存在的行。</li>
</ul>
<p>MySQL 8.0.19 之后可以给待插入的新行起别名：</p>
<pre><code>insert into student_design2
    (sno, name, age, class_id)
values
    ('20260001', '张三（已更新）', 19, 1) as new
on duplicate key update
    name = new.name,
    age = new.age,
    class_id = new.class_id;
</code></pre>
<p>这里通过学号唯一约束发现冲突，然后更新原来的学生记录。</p>
<blockquote>
<p>原资料中“受影响 2 行表示删除原记录再插入一条”的解释不准确。<code>on duplicate key update</code> 执行的是更新；默认连接行为下，更新已有行时受影响行数常显示为 2，这是 MySQL 的计数约定，不代表真的删除了两行或重新插入了一行。</p>
</blockquote>
<p>另外，旧写法中常见：</p>
<pre><code>name = values(name)
</code></pre>
<p>从 MySQL 8.0.20 起，这种在更新部分使用 <code>values()</code> 引用新值的方式已被弃用。新代码更适合使用上面的行别名。</p>
<h3>11.3 <code>REPLACE</code></h3>
<pre><code>replace into student_design2
    (id, sno, name, age, class_id)
values
    (1, '20260001', '张三', 20, 1);
</code></pre>
<p>逻辑上：</p>
<ul>
<li>没有主键或唯一键冲突：插入。</li>
<li>有冲突：删除冲突的旧行，再插入新行。</li>
</ul>
<p>如果单行 <code>replace</code> 显示：</p>
<ul>
<li><code>1 row affected</code>：通常只发生了插入。</li>
<li>大于 1：通常删除了冲突行并插入了新行。</li>
</ul>
<h3>11.4 三种写入方式对比</h3>
<table>
<thead>
<tr>
<th>写法</th>
<th>无冲突</th>
<th>有主键或唯一键冲突</th>
<th>适合场景</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>insert</code></td>
<td>插入</td>
<td>报错</td>
<td>必须确保是全新数据</td>
</tr>
<tr>
<td><code>insert ... on duplicate key update</code></td>
<td>插入</td>
<td>更新旧行</td>
<td>幂等写入、同步某些业务数据</td>
</tr>
<tr>
<td><code>replace</code></td>
<td>插入</td>
<td>删除旧行后插入</td>
<td>明确需要替换语义且已评估副作用</td>
</tr>
</tbody>
</table>
<hr />
<h2>参考</h2>
<p><a href="https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html">MySQL 8.0 Reference Manual - FOREIGN KEY Constraints</a></p>
<p><a href="https://dev.mysql.com/doc/refman/8.0/en/create-table-check-constraints.html">MySQL 8.0 Reference Manual - CHECK Constraints</a></p>
<p><a href="https://dev.mysql.com/doc/refman/8.0/en/create-table-like.html">MySQL 8.0 Reference Manual - CREATE TABLE ... LIKE</a></p>
<p><a href="https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html">MySQL 8.0 Reference Manual - INSERT ... ON DUPLICATE KEY UPDATE</a></p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>MySQL 基础操作：DDL、DML、DQL 与 CRUD</title>
    <link href="https://blog.syrena.cn/posts/database-crud/database-crud/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-crud/database-crud/</id>
    <published>2026-07-22T00:00:00.000Z</published>
    <updated>2026-07-22T00:00:00.000Z</updated>
    <summary>了解MySQL基本CRUD语法.</summary>
    <content type="html"><![CDATA[<blockquote>
<p>思路整理： <code>DDL -&gt; DML -&gt; DQL -&gt; 约束 -&gt; 数据库设计 -&gt; 多表查询 -&gt; 索引 -&gt; 事务 -&gt; 视图 -&gt; 用户权限 -&gt; JDBC</code> 学习。</p>
</blockquote>
<h1>0. MySQL 总览</h1>
<h2>0.1 数据库是什么</h2>
<p>数据库可以理解为：<strong>专门用来长期保存、管理、查询、修改数据的软件系统</strong>。</p>
<p>普通文件也能保存数据，但当数据量变大、多人同时访问、需要快速查询、需要保证数据安全时，普通文件就不够用了。数据库的价值主要在于：</p>
<table>
<thead>
<tr>
<th>作用</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据持久化</td>
<td>程序关闭或电脑重启后，数据仍然保存</td>
</tr>
<tr>
<td>结构化存储</td>
<td>数据按表、行、列组织，查询和维护方便</td>
</tr>
<tr>
<td>数据完整性</td>
<td>通过约束保证数据尽量正确，比如主键不能重复</td>
</tr>
<tr>
<td>并发控制</td>
<td>多个人同时操作数据时，尽量避免数据混乱</td>
</tr>
<tr>
<td>查询优化</td>
<td>数据库会通过索引、优化器提升查询效率</td>
</tr>
<tr>
<td>事务管理</td>
<td>一组操作要么都成功，要么都失败，适合转账、下单等场景</td>
</tr>
<tr>
<td>权限控制</td>
<td>不同用户只能操作自己有权限的数据</td>
</tr>
</tbody>
</table>
<h2>0.2 关系型数据库是什么</h2>
<p>关系型数据库用<strong>二维表</strong>保存数据。</p>
<p>一张表可以理解为 Excel 表格：</p>
<table>
<thead>
<tr>
<th>数据库概念</th>
<th>类比</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据库服务器</td>
<td>一台安装 MySQL 的机器</td>
<td>可以管理多个数据库</td>
</tr>
<tr>
<td>数据库 database/schema</td>
<td>一个项目的数据空间</td>
<td>一个项目一般建一个数据库</td>
</tr>
<tr>
<td>表 table</td>
<td>Excel 中的一张表</td>
<td>保存某一类实体的数据</td>
</tr>
<tr>
<td>行 row/record</td>
<td>Excel 中的一行</td>
<td>表示一条数据记录</td>
</tr>
<tr>
<td>列 column/field</td>
<td>Excel 中的一列</td>
<td>表示实体的一个属性</td>
</tr>
<tr>
<td>主键 primary key</td>
<td>唯一编号</td>
<td>用来唯一标识一行数据</td>
</tr>
</tbody>
</table>
<p>例如学生管理系统中：</p>
<pre><code>数据库：student_system
表：student、class、course、score
行：student 表中的一条学生记录
列：id、name、age、gender、class_id
</code></pre>
<h2>0.3 MySQL 服务端、客户端、数据库之间的关系</h2>
<p>MySQL 安装后主要有两个角度：</p>
<table>
<thead>
<tr>
<th>组成</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>mysqld</code></td>
<td>MySQL 服务端程序，真正管理数据</td>
</tr>
<tr>
<td><code>mysql</code></td>
<td>命令行客户端，用来连接服务端并发送 SQL</td>
</tr>
<tr>
<td>Navicat / DBeaver / Workbench</td>
<td>图形化客户端，本质也是连接 MySQL 服务端</td>
</tr>
</tbody>
</table>
<p>操作流程可以理解为：</p>
<pre><code>客户端输入 SQL
    -&gt; 发送给 MySQL 服务端
        -&gt; 服务端解析 SQL
            -&gt; 优化 SQL
                -&gt; 存储引擎读写数据文件
                    -&gt; 返回结果给客户端
</code></pre>
<h2>0.4 SQL 分类</h2>
<p>SQL 是结构化查询语言，用来操作关系型数据库。常见分类如下：</p>
<table>
<thead>
<tr>
<th>分类</th>
<th>全称</th>
<th>作用</th>
<th>常见语句</th>
</tr>
</thead>
<tbody>
<tr>
<td>DDL</td>
<td>Data Definition Language</td>
<td>定义数据库结构</td>
<td><code>create</code>、<code>drop</code>、<code>alter</code>、<code>truncate</code></td>
</tr>
<tr>
<td>DML</td>
<td>Data Manipulation Language</td>
<td>增删改表中数据</td>
<td><code>insert</code>、<code>update</code>、<code>delete</code></td>
</tr>
<tr>
<td>DQL</td>
<td>Data Query Language</td>
<td>查询数据</td>
<td><code>select</code></td>
</tr>
<tr>
<td>DCL</td>
<td>Data Control Language</td>
<td>权限控制</td>
<td><code>grant</code>、<code>revoke</code></td>
</tr>
<tr>
<td>TCL</td>
<td>Transaction Control Language</td>
<td>事务控制</td>
<td><code>start transaction</code>、<code>commit</code>、<code>rollback</code></td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>DDL 管结构，DML 改数据，DQL 查数据，DCL 管权限，TCL 管事务。</strong></p>
</blockquote>
<hr />
<h1>1. DDL：数据库和表结构操作</h1>
<p>DDL 主要负责<strong>创建、查看、修改、删除数据库结构</strong>。这里的结构包括数据库、表、字段、字段类型、字段约束等。</p>
<h2>1.1 数据库操作</h2>
<h3>1.1.1 查看所有数据库</h3>
<pre><code>show databases;
</code></pre>
<p><strong>为什么要先看数据库？</strong></p>
<p>因为 MySQL 服务端中可以存在多个数据库。操作前先查看已有数据库，可以避免误操作系统库或别的项目库。</p>
<p>系统库常见有：</p>
<table>
<thead>
<tr>
<th>系统库</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>mysql</code></td>
<td>保存用户、权限等系统信息</td>
</tr>
<tr>
<td><code>information_schema</code></td>
<td>保存数据库元数据，比如表、列、索引信息</td>
</tr>
<tr>
<td><code>performance_schema</code></td>
<td>性能监控相关数据</td>
</tr>
<tr>
<td><code>sys</code></td>
<td>系统视图，方便查看 MySQL 运行状态</td>
</tr>
</tbody>
</table>
<h3>1.1.2 查看当前正在使用的数据库</h3>
<pre><code>select database();
</code></pre>
<p><strong>原因：</strong></p>
<p>很多语句默认作用于当前数据库。如果当前数据库不是你想操作的库，就可能创建错表或删除错表。</p>
<h3>1.1.3 创建数据库</h3>
<p>基本语法：</p>
<pre><code>create database 数据库名;
</code></pre>
<p>推荐语法：</p>
<pre><code>create database if not exists 数据库名
default character set utf8mb4
collate utf8mb4_0900_ai_ci;
</code></pre>
<p>示例：</p>
<pre><code>create database if not exists mysql_study
default character set utf8mb4
collate utf8mb4_0900_ai_ci;
</code></pre>
<p><strong>每部分原因：</strong></p>
<table>
<thead>
<tr>
<th>写法</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>if not exists</code></td>
<td>数据库已存在时不报错，只给警告，适合重复执行脚本</td>
</tr>
<tr>
<td><code>utf8mb4</code></td>
<td>支持中文、emoji、更多 Unicode 字符</td>
</tr>
<tr>
<td><code>collate</code></td>
<td>指定排序和比较规则，比如大小写是否敏感</td>
</tr>
</tbody>
</table>
<h3>1.1.4 字符集与排序规则</h3>
<p>字符集决定<strong>字符怎么保存</strong>，排序规则决定<strong>字符怎么比较和排序</strong></p>
<p>查看 MySQL 支持的字符集：</p>
<pre><code>show charset;
</code></pre>
<p>查看排序规则：</p>
<pre><code>show collation;
</code></pre>
<p>查看当前服务器默认字符集：</p>
<pre><code>show variables like 'character_set%';
</code></pre>
<p>查看当前服务器默认排序规则：</p>
<pre><code>show variables like 'collation%';
</code></pre>
<p>常见规则：</p>
<table>
<thead>
<tr>
<th>排序规则</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>utf8mb4_0900_ai_ci</code></td>
<td>MySQL 8 默认常见规则，大小写不敏感、重音不敏感</td>
</tr>
<tr>
<td><code>utf8mb4_general_ci</code></td>
<td>老版本常见规则，兼容性较好</td>
</tr>
<tr>
<td><code>utf8mb4_bin</code></td>
<td>按二进制比较，大小写敏感</td>
</tr>
</tbody>
</table>
<p>其中：</p>
<table>
<thead>
<tr>
<th>缩写</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ci</code></td>
<td>case insensitive，大小写不敏感</td>
</tr>
<tr>
<td><code>cs</code></td>
<td>case sensitive，大小写敏感</td>
</tr>
<tr>
<td><code>ai</code></td>
<td>accent insensitive，重音不敏感</td>
</tr>
<tr>
<td><code>as</code></td>
<td>accent sensitive，重音敏感</td>
</tr>
</tbody>
</table>
<h3>1.1.5 使用数据库</h3>
<pre><code>use 数据库名;
</code></pre>
<p>示例：</p>
<pre><code>use mysql_study;
</code></pre>
<p><strong>原因：</strong></p>
<p>创建表、插入数据、查询数据通常都要在某个数据库中进行。执行 <code>use</code> 后，后续没有指定库名的表操作都会默认作用在这个库中。</p>
<h3>1.1.6 查看建库语句</h3>
<pre><code>show create database 数据库名;
</code></pre>
<p>示例：</p>
<pre><code>show create database mysql_study;
</code></pre>
<p><strong>原因：</strong></p>
<p>可以确认数据库的字符集、排序规则是否符合预期。</p>
<h3>1.1.7 修改数据库字符集和排序规则</h3>
<pre><code>alter database 数据库名
character set utf8mb4
collate utf8mb4_0900_ai_ci;
</code></pre>
<p>示例：</p>
<pre><code>alter database mysql_study
character set utf8mb4
collate utf8mb4_0900_ai_ci;
</code></pre>
<p><strong>注意：</strong></p>
<p>这主要影响后续新建对象的默认规则，不一定会自动改变已有表和已有字段的字符集。</p>
<h3>1.1.8 删除数据库</h3>
<pre><code>drop database if exists 数据库名;
</code></pre>
<p>示例：</p>
<pre><code>drop database if exists mysql_study;
</code></pre>
<p><strong>原因和风险：</strong></p>
<p><code>drop database</code> 会删除整个数据库，里面所有表和数据都会被删除。真实项目中不要随便执行，尤其不要在生产环境执行。</p>
<hr />
<h2>1.2 数据类型</h2>
<p>创建表时，每个字段都必须指定数据类型。</p>
<p><strong>选择数据类型的核心原则：</strong></p>
<ol>
<li>能准确表达业务含义；</li>
<li>范围够用，不浪费空间；</li>
<li>需要精确计算的不要用浮点数；</li>
<li>字符串长度尽量贴近实际业务；</li>
<li>时间字段根据是否需要日期、时间、时区特性来选。</li>
</ol>
<h3>1.2.1 常用数据类型分类</h3>
<table>
<thead>
<tr>
<th>分类</th>
<th>常用类型</th>
<th>适合保存</th>
</tr>
</thead>
<tbody>
<tr>
<td>整数类型</td>
<td><code>tinyint</code>、<code>smallint</code>、<code>int</code>、<code>bigint</code></td>
<td>年龄、编号、数量、状态</td>
</tr>
<tr>
<td>小数类型</td>
<td><code>decimal</code>、<code>float</code>、<code>double</code></td>
<td>金额、成绩、比例、测量值</td>
</tr>
<tr>
<td>字符串类型</td>
<td><code>char</code>、<code>varchar</code>、<code>text</code></td>
<td>姓名、手机号、邮箱、文章</td>
</tr>
<tr>
<td>日期时间类型</td>
<td><code>date</code>、<code>time</code>、<code>datetime</code>、<code>timestamp</code></td>
<td>生日、创建时间、更新时间</td>
</tr>
<tr>
<td>二进制类型</td>
<td><code>binary</code>、<code>varbinary</code>、<code>blob</code></td>
<td>图片、文件、二进制数据</td>
</tr>
</tbody>
</table>
<h3>1.2.2 整数类型</h3>
<table>
<thead>
<tr>
<th>类型</th>
<th>空间</th>
<th>有符号范围</th>
<th>无符号范围</th>
<th>常见用途</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>tinyint</code></td>
<td>1 byte</td>
<td>-128 ~ 127</td>
<td>0 ~ 255</td>
<td>状态、性别、年龄</td>
</tr>
<tr>
<td><code>smallint</code></td>
<td>2 bytes</td>
<td>-32768 ~ 32767</td>
<td>0 ~ 65535</td>
<td>小范围数量</td>
</tr>
<tr>
<td><code>mediumint</code></td>
<td>3 bytes</td>
<td>-8388608 ~ 8388607</td>
<td>0 ~ 16777215</td>
<td>中等范围编号</td>
</tr>
<tr>
<td><code>int</code></td>
<td>4 bytes</td>
<td>-2147483648 ~ 2147483647</td>
<td>0 ~ 4294967295</td>
<td>常规 ID、数量</td>
</tr>
<tr>
<td><code>bigint</code></td>
<td>8 bytes</td>
<td>很大</td>
<td>很大</td>
<td>大型系统 ID、雪花 ID</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>create table type_int_demo (
    id bigint primary key auto_increment,
    age tinyint unsigned,
    status tinyint,
    view_count int unsigned
);
</code></pre>
<p><strong>为什么年龄用 <strong><code>tinyint unsigned</code></strong>？</strong></p>
<p>年龄不会是负数，<code>tinyint unsigned</code> 范围是 0 到 255，已经足够。比 <code>int</code> 更节省空间。</p>
<p><strong>为什么 ID 常用 <strong><code>bigint</code></strong>？</strong></p>
<p>大型系统中数据量可能很大，分布式 ID 也经常超出 <code>int</code> 范围，所以用 <code>bigint</code> 更安全。</p>
<h3>1.2.3 小数类型</h3>
<table>
<thead>
<tr>
<th>类型</th>
<th>特点</th>
<th>适合场景</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>float</code></td>
<td>单精度浮点数，可能有精度误差</td>
<td>科学计算、要求不高的测量值</td>
</tr>
<tr>
<td><code>double</code></td>
<td>双精度浮点数，也可能有精度误差</td>
<td>高精度测量、非金额计算</td>
</tr>
<tr>
<td><code>decimal(M,D)</code></td>
<td>定点数，不存在二进制浮点误差</td>
<td>金额、工资、价格、财务数据</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>create table product (
    id bigint primary key auto_increment,
    name varchar(50) not null,
    price decimal(10, 2) not null
);
</code></pre>
<p><code>decimal(10,2)</code> 表示总共最多 10 位数字，其中小数点后 2 位。</p>
<p>可以保存：</p>
<pre><code>99999999.99
</code></pre>
<p>不能保存：</p>
<pre><code>999999999.99
</code></pre>
<p>因为总位数超过 10。</p>
<p><strong>为什么金额不要用 float/double？</strong></p>
<p>浮点数在计算机底层用二进制近似表示，可能出现 <code>0.1 + 0.2 != 0.3</code> 这样的精度误差。金额必须精确，所以用 <code>decimal</code>。</p>
<h3>1.2.4 字符串类型</h3>
<table>
<thead>
<tr>
<th>类型</th>
<th>特点</th>
<th>最大长度</th>
<th>适合保存</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>char(n)</code></td>
<td>固定长度</td>
<td>0~255 字符</td>
<td>身份证号、MD5、性别编码</td>
</tr>
<tr>
<td><code>varchar(n)</code></td>
<td>可变长度</td>
<td>取决于行大小和字符集</td>
<td>姓名、标题、邮箱、手机号</td>
</tr>
<tr>
<td><code>text</code></td>
<td>长文本</td>
<td>较大</td>
<td>文章、评论、描述</td>
</tr>
</tbody>
</table>
<h4>char 与 varchar 的区别</h4>
<table>
<thead>
<tr>
<th>对比</th>
<th><code>char(n)</code></th>
<th><code>varchar(n)</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>长度</td>
<td>固定长度</td>
<td>可变长度</td>
</tr>
<tr>
<td>空间</td>
<td>不足 n 也按 n 存储，可能补空格</td>
<td>按实际长度存储，额外记录长度</td>
</tr>
<tr>
<td>查询效率</td>
<td>通常略快</td>
<td>通常略慢</td>
</tr>
<tr>
<td>适合</td>
<td>长度固定的数据</td>
<td>长度不固定的数据</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>create table user_account (
    id bigint primary key auto_increment,
    username varchar(30) not null,
    password_md5 char(32) not null,
    phone char(11),
    intro text
);
</code></pre>
<p><strong>为什么密码 MD5 用 <strong><code>char(32)</code></strong>？</strong></p>
<p>MD5 字符串固定 32 位，长度不会变化，用 <code>char(32)</code> 合适。</p>
<p><strong>为什么用户名用 <strong><code>varchar(30)</code></strong>？</strong></p>
<p>用户名长度不固定，有的人 3 个字符，有的人 20 个字符，用 <code>varchar</code> 更节省空间。</p>
<h3>1.2.5 日期时间类型</h3>
<table>
<thead>
<tr>
<th>类型</th>
<th>格式</th>
<th>适合保存</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>date</code></td>
<td><code>YYYY-MM-DD</code></td>
<td>生日、日期</td>
</tr>
<tr>
<td><code>time</code></td>
<td><code>HH:MM:SS</code></td>
<td>时间点、耗时</td>
</tr>
<tr>
<td><code>datetime</code></td>
<td><code>YYYY-MM-DD HH:MM:SS</code></td>
<td>创建时间、更新时间</td>
</tr>
<tr>
<td><code>timestamp</code></td>
<td><code>YYYY-MM-DD HH:MM:SS</code></td>
<td>时间戳，受时区影响</td>
</tr>
<tr>
<td><code>year</code></td>
<td><code>YYYY</code></td>
<td>年份</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>create table article (
    id bigint primary key auto_increment,
    title varchar(100) not null,
    publish_date date,
    created_at datetime default current_timestamp,
    updated_at datetime default current_timestamp on update current_timestamp
);
</code></pre>
<p><strong>为什么创建时间用 <strong><code>datetime</code></strong>？</strong></p>
<p>创建时间需要日期和具体时间，不只是日期，所以不用 <code>date</code>。</p>
<p><strong><code>datetime</code> 与 <code>timestamp</code> 简单区别：</strong></p>
<table>
<thead>
<tr>
<th>对比</th>
<th><code>datetime</code></th>
<th><code>timestamp</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>时区影响</td>
<td>不受时区自动转换影响</td>
<td>受时区转换影响</td>
</tr>
<tr>
<td>范围</td>
<td>更大</td>
<td>较小</td>
</tr>
<tr>
<td>常用场景</td>
<td>业务时间</td>
<td>记录系统时间戳</td>
</tr>
</tbody>
</table>
<hr />
<h2>1.3 表操作：查询、创建、修改、删除</h2>
<h3>1.3.1 查看当前数据库中的所有表</h3>
<pre><code>show tables;
</code></pre>
<p><strong>原因：</strong></p>
<p>确认当前数据库里有哪些表，避免重复建表或误删表。</p>
<h3>1.3.2 查看表结构</h3>
<pre><code>desc 表名;
</code></pre>
<p>示例：</p>
<pre><code>desc users;
</code></pre>
<p>结果中常见字段：</p>
<table>
<thead>
<tr>
<th>列</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Field</code></td>
<td>字段名</td>
</tr>
<tr>
<td><code>Type</code></td>
<td>字段类型</td>
</tr>
<tr>
<td><code>Null</code></td>
<td>是否允许为 <code>NULL</code></td>
</tr>
<tr>
<td><code>Key</code></td>
<td>是否有索引/主键/唯一键</td>
</tr>
<tr>
<td><code>Default</code></td>
<td>默认值</td>
</tr>
<tr>
<td><code>Extra</code></td>
<td>额外信息，比如 <code>auto_increment</code></td>
</tr>
</tbody>
</table>
<h3>1.3.3 查看建表语句</h3>
<pre><code>show create table 表名;
</code></pre>
<p>示例：</p>
<pre><code>show create table users;
</code></pre>
<p><strong>原因：</strong></p>
<p><code>desc</code> 只能看大概结构，<code>show create table</code> 能看到完整建表语句，包括字符集、引擎、约束、索引等。</p>
<h3>1.3.4 创建表基本语法</h3>
<pre><code>create table 表名 (
    字段名1 数据类型 [约束] [comment '字段注释'],
    字段名2 数据类型 [约束] [comment '字段注释'],
    字段名3 数据类型 [约束] [comment '字段注释']
) engine = InnoDB
  default character set = utf8mb4
  collate = utf8mb4_0900_ai_ci
  comment = '表注释';
</code></pre>
<p>示例：</p>
<pre><code>drop table if exists users;

create table users (
    id bigint primary key auto_increment comment '用户ID',
    username varchar(30) not null unique comment '用户名',
    password char(32) not null comment 'MD5密码',
    phone char(11) comment '手机号',
    birthday date comment '生日',
    created_at datetime default current_timestamp comment '创建时间'
) engine = InnoDB
  default character set = utf8mb4
  collate = utf8mb4_0900_ai_ci
  comment = '用户表';
</code></pre>
<p><strong>每一步原因：</strong></p>
<table>
<thead>
<tr>
<th>操作</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>drop table if exists users</code></td>
<td>练习时方便重复执行，避免表已存在报错</td>
</tr>
<tr>
<td><code>id bigint primary key auto_increment</code></td>
<td>每条用户数据都需要唯一标识，并自动递增</td>
</tr>
<tr>
<td><code>username not null unique</code></td>
<td>用户名不能为空，也不能重复</td>
</tr>
<tr>
<td><code>password char(32)</code></td>
<td>MD5 固定 32 位</td>
</tr>
<tr>
<td><code>created_at default current_timestamp</code></td>
<td>插入时自动记录创建时间</td>
</tr>
<tr>
<td><code>engine = InnoDB</code></td>
<td>InnoDB 支持事务、外键、行级锁，是 MySQL 常用默认引擎</td>
</tr>
</tbody>
</table>
<h3>1.3.5 修改表结构：ALTER TABLE</h3>
<h4>添加字段</h4>
<pre><code>alter table 表名 add 字段名 数据类型 [约束] [comment '注释'];
</code></pre>
<p>示例：</p>
<pre><code>alter table users add email varchar(100) comment '邮箱';
</code></pre>
<p><strong>原因：</strong></p>
<p>项目需求变化时，可能需要给已有表增加新属性。</p>
<h4>修改字段类型</h4>
<pre><code>alter table 表名 modify 字段名 新数据类型 [约束];
</code></pre>
<p>示例：</p>
<pre><code>alter table users modify username varchar(50) not null;
</code></pre>
<p><strong>原因：</strong></p>
<p>原来用户名长度 30 不够，需要扩大为 50。</p>
<h4>修改字段名和字段类型</h4>
<pre><code>alter table 表名 change 旧字段名 新字段名 新数据类型 [约束] [comment '注释'];
</code></pre>
<p>示例：</p>
<pre><code>alter table users change phone mobile char(11) comment '手机号';
</code></pre>
<p><strong>原因：</strong></p>
<p><code>change</code> 可以同时改字段名和字段类型。即使只改字段名，也必须写新类型。</p>
<h4>删除字段</h4>
<pre><code>alter table 表名 drop column 字段名;
</code></pre>
<p>示例：</p>
<pre><code>alter table users drop column email;
</code></pre>
<p><strong>注意：</strong></p>
<p>删除字段会丢失这一列的所有数据，真实项目中要先备份或确认业务不再使用。</p>
<h4>修改表名</h4>
<pre><code>alter table 旧表名 rename to 新表名;
</code></pre>
<p>示例：</p>
<pre><code>alter table users rename to user_account;
</code></pre>
<h3>1.3.6 删除表</h3>
<pre><code>drop table if exists 表名;
</code></pre>
<p>示例：</p>
<pre><code>drop table if exists user_account;
</code></pre>
<p><strong>原因和风险：</strong></p>
<p><code>drop table</code> 删除表结构和数据，表直接不存在。比 <code>delete</code> 更危险。</p>
<h3>1.3.7 清空表：TRUNCATE</h3>
<pre><code>truncate table 表名;
</code></pre>
<p>或者：</p>
<pre><code>truncate 表名;
</code></pre>
<p><code>delete</code> 与 <code>truncate</code> 对比：</p>
<table>
<thead>
<tr>
<th>对比项</th>
<th><code>delete from 表名;</code></th>
<th><code>truncate table 表名;</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>删除对象</td>
<td>删除数据行</td>
<td>清空整张表</td>
</tr>
<tr>
<td>是否保留表结构</td>
<td>保留</td>
<td>保留</td>
</tr>
<tr>
<td>是否能加 <code>where</code></td>
<td>可以</td>
<td>不可以</td>
</tr>
<tr>
<td>自增计数</td>
<td>通常不重置或依情况</td>
<td>通常重置</td>
</tr>
<tr>
<td>适合场景</td>
<td>按条件删除</td>
<td>清空测试表</td>
</tr>
<tr>
<td>危险程度</td>
<td>高</td>
<td>更高</td>
</tr>
</tbody>
</table>
<p><strong>注意：</strong></p>
<p><code>truncate</code> 不是删除表，而是清空表。初学阶段只建议在测试表上使用。</p>
<hr />
<h1>2. DML：数据增删改</h1>
<p>DML 操作的是<strong>表中的数据行</strong>，不是表结构。</p>
<p>常见 DML：</p>
<table>
<thead>
<tr>
<th>操作</th>
<th>语句</th>
</tr>
</thead>
<tbody>
<tr>
<td>新增</td>
<td><code>insert</code></td>
</tr>
<tr>
<td>修改</td>
<td><code>update</code></td>
</tr>
<tr>
<td>删除</td>
<td><code>delete</code></td>
</tr>
</tbody>
</table>
<hr />
<h2>2.1 INSERT：添加数据</h2>
<h3>2.1.1 指定字段插入</h3>
<pre><code>insert into 表名(字段1, 字段2, 字段3)
values(值1, 值2, 值3);
</code></pre>
<p>示例：</p>
<pre><code>insert into users(username, password, phone)
values('zhangsan', 'e10adc3949ba59abbe56e057f20f883e', '13800000000');
</code></pre>
<p><strong>原因：</strong></p>
<p>推荐指定字段插入，因为表结构变化时不容易出错。例如后面增加一个字段，原来的插入语句仍然能执行。</p>
<h3>2.1.2 全字段插入</h3>
<pre><code>insert into 表名 values(值1, 值2, 值3, ...);
</code></pre>
<p>示例：</p>
<pre><code>insert into users
values(1, 'lisi', 'e10adc3949ba59abbe56e057f20f883e', '13900000000', '2000-01-01', now());
</code></pre>
<p><strong>注意：</strong></p>
<p>全字段插入要求值的个数、顺序必须和表结构完全一致。真实项目中不推荐。</p>
<h3>2.1.3 批量插入</h3>
<pre><code>insert into 表名(字段1, 字段2)
values
(值1, 值2),
(值1, 值2),
(值1, 值2);
</code></pre>
<p>示例：</p>
<pre><code>insert into users(username, password, phone)
values
('wangwu', '12345678901234567890123456789012', '13700000000'),
('zhaoliu', '12345678901234567890123456789012', '13600000000'),
('qianqi', '12345678901234567890123456789012', '13500000000');
</code></pre>
<p><strong>原因：</strong></p>
<p>批量插入比多次单行插入效率更高，因为一次 SQL 可以插入多行数据。</p>
<h3>2.1.4 插入查询结果</h3>
<p>对应语法为：</p>
<pre><code>insert into 新表 (列1, 列2, ...)
select 列1, 列2, ...
from 旧表
where 条件;
</code></pre>
<p>同样在此基础上还能继续拓展多种操作，比如带条件迁移、迁移时修改数据，该知识点另有补充。</p>
<h3>2.1.5 插入注意事项</h3>
<ol>
<li>字符串和日期要加单引号：</li>
</ol>
<pre><code>'张三'
'2026-05-08'
</code></pre>
<ol>
<li>数字一般不用引号：</li>
</ol>
<pre><code>18
99.5
</code></pre>
<ol>
<li>插入指定字段时，值的顺序必须和字段顺序一致。</li>
<li>有 <code>auto_increment</code> 的主键通常不用手动插入。</li>
</ol>
<hr />
<h2>2.2 UPDATE：修改数据</h2>
<p>基本语法：</p>
<pre><code>update 表名
set 字段1 = 值1,
    字段2 = 值2
where 条件;
</code></pre>
<p>示例：</p>
<pre><code>update users
set phone = '13811112222'
where username = 'zhangsan';
</code></pre>
<p><strong>每一步原因：</strong></p>
<table>
<thead>
<tr>
<th>部分</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>update users</code></td>
<td>指定要修改哪张表</td>
</tr>
<tr>
<td><code>set phone = ...</code></td>
<td>指定要修改哪个字段、改成什么值</td>
</tr>
<tr>
<td><code>where username = ...</code></td>
<td>限定只修改某个用户</td>
</tr>
</tbody>
</table>
<h3>2.2.1 修改多个字段</h3>
<pre><code>update users
set phone = '13811112222',
    birthday = '2001-02-03'
where id = 1;
</code></pre>
<h3>2.2.2 没有 WHERE 的风险</h3>
<pre><code>update users
set phone = '00000000000';
</code></pre>
<p>这会把整张表所有用户的手机号都改掉。</p>
<p><strong>结论：</strong></p>
<p>执行 <code>update</code> 前先写 <code>select</code> 验证条件：</p>
<pre><code>select * from users where id = 1;
</code></pre>
<p>确认结果正确后，再执行：</p>
<pre><code>update users set phone = '13811112222' where id = 1;
</code></pre>
<hr />
<h2>2.3 DELETE：删除数据</h2>
<p>基本语法：</p>
<pre><code>delete from 表名 where 条件;
</code></pre>
<p>示例：</p>
<pre><code>delete from users where id = 3;
</code></pre>
<p><strong>原因：</strong></p>
<p><code>where</code> 用来限定删除范围。没有 <code>where</code> 会删除整张表所有数据。</p>
<h3>2.3.1 删除字段为 NULL 的记录</h3>
<pre><code>delete from users where phone is null;
</code></pre>
<p><strong>为什么不能写 <strong><code>phone = null</code></strong>？</strong></p>
<p>SQL 中 <code>NULL</code> 表示未知值，不能用 <code>=</code> 判断，必须用：</p>
<pre><code>is null
is not null
</code></pre>
<h3>2.3.2 DELETE 不能删除某一列的值</h3>
<p>错误理解：</p>
<pre><code>delete phone from users where id = 1;
</code></pre>
<p><code>delete</code> 删除的是整行数据，不是某个字段值。</p>
<p>如果只想清空手机号，应使用：</p>
<pre><code>update users set phone = null where id = 1;
</code></pre>
<p>或者：</p>
<pre><code>update users set phone = '' where id = 1;
</code></pre>
<p>区别：</p>
<table>
<thead>
<tr>
<th>写法</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>null</code></td>
<td>没有值、未知</td>
</tr>
<tr>
<td><code>''</code></td>
<td>空字符串，是一个具体字符串值</td>
</tr>
</tbody>
</table>
<hr />
<h1>3. DQL：数据查询</h1>
<p>DQL 的核心是 <code>select</code>。查询是 MySQL 中最常用、也是最重要的部分。</p>
<h2>3.1 SELECT 语句完整结构</h2>
<p>书写顺序：</p>
<pre><code>select 字段列表
from 表名
where 条件列表
group by 分组字段
having 分组后条件
order by 排序字段
limit 分页参数;
</code></pre>
<p>执行顺序大致理解为：</p>
<pre><code>from -&gt; where -&gt; group by -&gt; 聚合函数 -&gt; having -&gt; select -&gt; order by -&gt; limit
</code></pre>
<p><strong>为什么要理解执行顺序？</strong></p>
<p>因为它能解释很多问题：</p>
<ol>
<li><code>where</code> 不能使用聚合函数，因为聚合还没执行；</li>
<li><code>having</code> 可以使用聚合函数，因为它在分组统计之后执行；</li>
<li><code>order by</code> 可以使用 <code>select</code> 中的别名，因为排序发生在 select 后面。</li>
</ol>
<hr />
<h2>3.2 基本查询</h2>
<h3>3.2.1 查询所有列</h3>
<pre><code>select * from 表名;
</code></pre>
<p>示例：</p>
<pre><code>select * from users;
</code></pre>
<p><strong>注意：</strong></p>
<p>练习时可以用 <code>*</code>，真实项目中尽量写具体字段。因为 <code>*</code> 会查询所有列，可能浪费网络和内存，也容易受表结构变化影响。</p>
<h3>3.2.2 查询指定列</h3>
<pre><code>select 字段1, 字段2 from 表名;
</code></pre>
<p>示例：</p>
<pre><code>select id, username, phone from users;
</code></pre>
<p><strong>原因：</strong></p>
<p>只查询需要的列，可以减少数据传输，提高查询效率。</p>
<h3>3.2.3 查询表达式</h3>
<pre><code>select username, 10 from users;
</code></pre>
<pre><code>select username, id + 1000 from users;
</code></pre>
<p>示例：</p>
<pre><code>select username, year(now()) - year(birthday) as approx_age
from users;
</code></pre>
<p><strong>原因：</strong></p>
<p><code>select</code> 后面不只能写字段，也可以写表达式、函数、常量。</p>
<h3>3.2.4 设置别名</h3>
<pre><code>select 字段名 as 别名 from 表名;
</code></pre>
<p><code>as</code> 可以省略：</p>
<pre><code>select username 用户名 from users;
</code></pre>
<p>推荐写法：</p>
<pre><code>select username as 用户名, phone as 手机号
from users;
</code></pre>
<p><strong>原因：</strong></p>
<p>别名可以让查询结果更易读，尤其是表达式和函数结果。</p>
<h3>3.2.5 去重查询 DISTINCT</h3>
<pre><code>select distinct 字段名 from 表名;
</code></pre>
<p>示例：</p>
<pre><code>select distinct phone from users;
</code></pre>
<p>多个字段去重：</p>
<pre><code>select distinct class_id, gender from student;
</code></pre>
<p><strong>注意：</strong></p>
<p>多个字段一起 <code>distinct</code> 时，判断的是这一组字段组合是否重复，不是只看某一个字段。</p>
<hr />
<h2>3.3 WHERE 条件查询</h2>
<p>基本语法：</p>
<pre><code>select 字段列表
from 表名
where 条件;
</code></pre>
<h3>3.3.1 比较运算符</h3>
<table>
<thead>
<tr>
<th>运算符</th>
<th>含义</th>
<th>示例</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>=</code></td>
<td>等于</td>
<td><code>age = 18</code></td>
</tr>
<tr>
<td><code>&lt;&gt;</code> 或 <code>!=</code></td>
<td>不等于</td>
<td><code>age &lt;&gt; 18</code></td>
</tr>
<tr>
<td><code>&gt;</code></td>
<td>大于</td>
<td><code>score &gt; 90</code></td>
</tr>
<tr>
<td><code>&gt;=</code></td>
<td>大于等于</td>
<td><code>score &gt;= 60</code></td>
</tr>
<tr>
<td><code>&lt;</code></td>
<td>小于</td>
<td><code>age &lt; 30</code></td>
</tr>
<tr>
<td><code>&lt;=</code></td>
<td>小于等于</td>
<td><code>age &lt;= 30</code></td>
</tr>
<tr>
<td><code>between ... and ...</code></td>
<td>在范围内，包含两端</td>
<td><code>age between 18 and 25</code></td>
</tr>
<tr>
<td><code>in (...)</code></td>
<td>在列表中</td>
<td><code>age in (18,20,22)</code></td>
</tr>
<tr>
<td><code>like</code></td>
<td>模糊匹配</td>
<td><code>name like '张%'</code></td>
</tr>
<tr>
<td><code>is null</code></td>
<td>是空值</td>
<td><code>phone is null</code></td>
</tr>
<tr>
<td><code>is not null</code></td>
<td>不是空值</td>
<td><code>phone is not null</code></td>
</tr>
</tbody>
</table>
<h3>3.3.2 逻辑运算符</h3>
<table>
<thead>
<tr>
<th>运算符</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>and</code></td>
<td>并且，多个条件都满足</td>
</tr>
<tr>
<td><code>or</code></td>
<td>或者，满足任意一个条件</td>
</tr>
<tr>
<td><code>not</code></td>
<td>取反</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>select * from student
where age &gt;= 18 and gender = 1;
</code></pre>
<pre><code>select * from student
where age = 18 or age = 20;
</code></pre>
<p>推荐改成：</p>
<pre><code>select * from student
where age in (18, 20);
</code></pre>
<h3>3.3.3 BETWEEN 范围查询</h3>
<pre><code>select * from student
where age between 18 and 25;
</code></pre>
<p>等价于：</p>
<pre><code>select * from student
where age &gt;= 18 and age &lt;= 25;
</code></pre>
<p><strong>注意：</strong></p>
<p><code>between 小值 and 大值</code> 包含两端。</p>
<h3>3.3.4 IN 多选一</h3>
<pre><code>select * from student
where age in (18, 20, 40);
</code></pre>
<p><strong>原因：</strong></p>
<p>比多个 <code>or</code> 更简洁。</p>
<h3>3.3.5 LIKE 模糊查询</h3>
<table>
<thead>
<tr>
<th>通配符</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>%</code></td>
<td>匹配任意个字符，包括 0 个</td>
</tr>
<tr>
<td><code>_</code></td>
<td>匹配单个字符</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<p>查询姓张的人：</p>
<pre><code>select * from student
where name like '张%';
</code></pre>
<p>查询名字只有两个字的人：</p>
<pre><code>select * from student
where name like '__';
</code></pre>
<p>查询身份证号最后一位是 X：</p>
<pre><code>select * from student
where id_card like '%X';
</code></pre>
<p><strong>注意：</strong></p>
<p><code>like '%关键字%'</code> 前面有 <code>%</code> 时，普通 B+Tree 索引通常很难有效使用。</p>
<h3>3.3.6 NULL 查询</h3>
<p>查询手机号为空：</p>
<pre><code>select * from users where phone is null;
</code></pre>
<p>查询手机号不为空：</p>
<pre><code>select * from users where phone is not null;
</code></pre>
<p>错误写法：</p>
<pre><code>select * from users where phone = null;
</code></pre>
<hr />
<h2>3.4 ORDER BY 排序查询</h2>
<p>基本语法：</p>
<pre><code>select 字段列表
from 表名
order by 字段1 排序方式, 字段2 排序方式;
</code></pre>
<p>排序方式：</p>
<table>
<thead>
<tr>
<th>写法</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>asc</code></td>
<td>升序，默认</td>
</tr>
<tr>
<td><code>desc</code></td>
<td>降序</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>select * from student
order by age asc;
</code></pre>
<pre><code>select * from exam
order by math desc, english desc;
</code></pre>
<p><strong>原因：</strong></p>
<p>当第一个排序字段相同时，再按第二个排序字段排序。</p>
<hr />
<h2>3.5 LIMIT 分页查询</h2>
<h3>3.5.1 基本语法</h3>
<pre><code>select 字段列表
from 表名
limit 起始下标, 查询条数;
</code></pre>
<p>示例：</p>
<pre><code>select * from student limit 0, 10;
</code></pre>
<p>表示从第 0 条开始，查询 10 条。</p>
<h3>3.5.2 第 n 页公式</h3>
<pre><code>起始下标 = (页码 - 1) * 每页条数
</code></pre>
<p>第 1 页，每页 10 条：</p>
<pre><code>limit 0, 10;
</code></pre>
<p>第 2 页，每页 10 条：</p>
<pre><code>limit 10, 10;
</code></pre>
<p>第 3 页，每页 10 条：</p>
<pre><code>limit 20, 10;
</code></pre>
<h3>3.5.3 另一种写法</h3>
<pre><code>select * from student
limit 10 offset 20;
</code></pre>
<p>等价于：</p>
<pre><code>select * from student
limit 20, 10;
</code></pre>
<hr />
<h2>3.6 聚合函数</h2>
<p>聚合函数用于对多行（即某一整列）数据进行统计，最后通常返回一行结果。</p>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>count()</code></td>
<td>统计数量</td>
</tr>
<tr>
<td><code>sum()</code></td>
<td>求和</td>
</tr>
<tr>
<td><code>avg()</code></td>
<td>求平均值</td>
</tr>
<tr>
<td><code>max()</code></td>
<td>求最大值</td>
</tr>
<tr>
<td><code>min()</code></td>
<td>求最小值</td>
</tr>
</tbody>
</table>
<h3>3.6.1 COUNT</h3>
<p>统计总行数：</p>
<pre><code>select count(*) from student;
</code></pre>
<p>统计某列非 NULL 的数量：</p>
<pre><code>select count(phone) from users;
</code></pre>
<p>区别：</p>
<table>
<thead>
<tr>
<th>写法</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>count(*)</code></td>
<td>统计行数，包括字段为 NULL 的行</td>
</tr>
<tr>
<td><code>count(字段)</code></td>
<td>统计该字段不为 NULL 的行数</td>
</tr>
</tbody>
</table>
<h3>3.6.2 SUM</h3>
<pre><code>select sum(math) from exam;
</code></pre>
<h3>3.6.3 AVG</h3>
<pre><code>select avg(math) from exam;
</code></pre>
<p>保留两位小数：</p>
<pre><code>select round(avg(math), 2) as avg_math
from exam;
</code></pre>
<h3>3.6.4 MAX / MIN</h3>
<pre><code>select max(math) from exam;
select min(math) from exam;
</code></pre>
<h3>3.6.5 聚合函数注意事项</h3>
<p>不带普通字段时，不需要 <code>group by</code>：</p>
<pre><code>select avg(math) from exam;
</code></pre>
<p>聚合函数和普通字段一起出现时，普通字段通常必须配合 <code>group by</code>：</p>
<pre><code>select class_name, avg(math)
from exam
group by class_name;
</code></pre>
<hr />
<h2>3.7 GROUP BY 分组查询</h2>
<h3>3.7.1 基本语法</h3>
<pre><code>select 分组字段, 聚合函数(字段)
from 表名
where 分组前条件
group by 分组字段
having 分组后条件;
</code></pre>
<h3>3.7.2 按班级统计平均成绩</h3>
<pre><code>select
    class_name,
    round(avg(chinese), 2) as avg_chinese,
    round(avg(math), 2) as avg_math,
    round(avg(english), 2) as avg_english
from exam
group by class_name;
</code></pre>
<p><strong>执行逻辑：</strong></p>
<ol>
<li>先从 <code>exam</code> 表取数据；</li>
<li>按 <code>class_name</code> 分组；</li>
<li>每组分别计算语文、数学、英语平均分；</li>
<li>返回每组统计结果。</li>
</ol>
<h3>3.7.3 WHERE 与 HAVING 区别</h3>
<table>
<thead>
<tr>
<th>对比</th>
<th><code>where</code></th>
<th><code>having</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>执行时间</td>
<td>分组前</td>
<td>分组后</td>
</tr>
<tr>
<td>过滤对象</td>
<td>原始数据行</td>
<td>分组后的结果</td>
</tr>
<tr>
<td>能否使用聚合函数</td>
<td>不能</td>
<td>能</td>
</tr>
<tr>
<td>常见用途</td>
<td>先筛选有效数据</td>
<td>筛选统计结果</td>
</tr>
</tbody>
</table>
<h3>3.7.4 WHERE 例子：先过滤学生，再分组</h3>
<p>查询数学成绩 &gt;= 80 的学生，再按班级统计人数：</p>
<pre><code>select
    class_name,
    count(*) as student_count
from exam
where math &gt;= 80
group by class_name;
</code></pre>
<p>逻辑：</p>
<ol>
<li>先筛选 <code>math &gt;= 80</code> 的学生；</li>
<li>再按照 <code>class_name</code> 分组；</li>
<li>最后统计每个班剩下多少人。</li>
</ol>
<h3>3.7.5 HAVING 例子：先分组统计，再过滤结果</h3>
<p>查询平均数学成绩 &gt;= 80 的班级：</p>
<pre><code>select
    class_name,
    round(avg(math), 2) as avg_math
from exam
group by class_name
having avg(math) &gt;= 80;
</code></pre>
<p>逻辑：</p>
<ol>
<li>先按班级分组；</li>
<li>计算每个班平均数学成绩；</li>
<li>保留平均数学成绩 &gt;= 80 的班级。</li>
</ol>
<hr />
<h2>3.8 内置函数</h2>
<h2>3.8.1 日期函数</h2>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
<th>示例</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>curdate()</code></td>
<td>当前日期</td>
<td><code>select curdate();</code></td>
</tr>
<tr>
<td><code>curtime()</code></td>
<td>当前时间</td>
<td><code>select curtime();</code></td>
</tr>
<tr>
<td><code>now()</code></td>
<td>当前日期和时间</td>
<td><code>select now();</code></td>
</tr>
<tr>
<td><code>date()</code></td>
<td>提取日期部分</td>
<td><code>select date('2026-05-08 14:30:25');</code></td>
</tr>
<tr>
<td><code>adddate()</code></td>
<td>日期增加</td>
<td><code>select adddate('2026-05-08', interval 7 day);</code></td>
</tr>
<tr>
<td><code>subdate()</code></td>
<td>日期减少</td>
<td><code>select subdate('2026-05-08', interval 7 day);</code></td>
</tr>
<tr>
<td><code>datediff()</code></td>
<td>两个日期相差天数</td>
<td><code>select datediff('2026-05-08','2026-05-01');</code></td>
</tr>
</tbody>
</table>
<p>示例：查询最近 7 天注册的用户：</p>
<pre><code>select * from users
where created_at &gt;= subdate(now(), interval 7 day);
</code></pre>
<h3>3.8.2 字符串函数</h3>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>concat()</code></td>
<td>拼接字符串</td>
</tr>
<tr>
<td><code>char_length()</code></td>
<td>统计字符个数</td>
</tr>
<tr>
<td><code>length()</code></td>
<td>统计字节数</td>
</tr>
<tr>
<td><code>lower()</code></td>
<td>转小写</td>
</tr>
<tr>
<td><code>upper()</code></td>
<td>转大写</td>
</tr>
<tr>
<td><code>substring()</code></td>
<td>截取字符串</td>
</tr>
<tr>
<td><code>replace()</code></td>
<td>替换字符串</td>
</tr>
<tr>
<td><code>trim()</code></td>
<td>去掉首尾空格</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>select concat(username, '的手机号是：', phone) as info
from users;
</code></pre>
<pre><code>select char_length('你好MySQL');
</code></pre>
<p>注意：</p>
<pre><code>char_length('你好')
</code></pre>
<p>返回字符数；</p>
<pre><code>length('你好')
</code></pre>
<p>返回字节数，和字符集有关。</p>
<h3>3.8.3 数学函数</h3>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>abs()</code></td>
<td>绝对值</td>
</tr>
<tr>
<td><code>ceil()</code></td>
<td>向上取整</td>
</tr>
<tr>
<td><code>floor()</code></td>
<td>向下取整</td>
</tr>
<tr>
<td><code>round()</code></td>
<td>四舍五入</td>
</tr>
<tr>
<td><code>rand()</code></td>
<td>随机数</td>
</tr>
<tr>
<td><code>mod()</code></td>
<td>取余</td>
</tr>
<tr>
<td><code>pow()</code></td>
<td>幂运算</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>select round(avg(math), 2) from exam;
</code></pre>
<p>生成随机数：</p>
<pre><code>select rand();
</code></pre>
<p>生成 6 位随机数思路：</p>
<pre><code>select floor(rand() * 1000000);
</code></pre>
<hr />
<h1>4. 数据库约束</h1>
<p>约束是对表中数据的规则限制，用来保证数据准确、完整、可靠。</p>
<p>常见约束：</p>
<table>
<thead>
<tr>
<th>约束</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>not null</code></td>
<td>非空，不能存 <code>NULL</code></td>
</tr>
<tr>
<td><code>default</code></td>
<td>默认值</td>
</tr>
<tr>
<td><code>unique</code></td>
<td>唯一，不能重复</td>
</tr>
<tr>
<td><code>primary key</code></td>
<td>主键，非空且唯一</td>
</tr>
<tr>
<td><code>foreign key</code></td>
<td>外键，保证表之间关系正确</td>
</tr>
<tr>
<td><code>check</code></td>
<td>检查值是否满足条件</td>
</tr>
</tbody>
</table>
<hr />
<h2>4.1 NOT NULL 非空约束</h2>
<p>示例：</p>
<pre><code>create table student (
    id bigint,
    name varchar(20) not null
);
</code></pre>
<p>插入失败：</p>
<pre><code>insert into student values(1, null);
</code></pre>
<p><strong>原因：</strong></p>
<p>学生姓名如果为 <code>NULL</code>，这条学生记录没有实际意义，所以要限制不能为空。</p>
<hr />
<h2>4.2 DEFAULT 默认值约束</h2>
<p>示例：</p>
<pre><code>create table student (
    id bigint,
    name varchar(20) not null,
    age int default 18
);
</code></pre>
<p>插入时不指定年龄：</p>
<pre><code>insert into student(id, name) values(1, '张三');
</code></pre>
<p>查询结果中 <code>age</code> 自动为 18。</p>
<p><strong>原因：</strong></p>
<p>当某些字段大多数情况下都有相同初始值时，可以用默认值减少插入语句复杂度。</p>
<hr />
<h2>4.3 UNIQUE 唯一约束</h2>
<p>示例：</p>
<pre><code>create table users (
    id bigint primary key auto_increment,
    username varchar(30) not null unique,
    phone char(11) unique
);
</code></pre>
<p><strong>原因：</strong></p>
<p>用户名、手机号通常不能重复，否则登录、找回密码等业务会出现混乱。</p>
<p><strong>注意：</strong></p>
<p>唯一约束允许多个 <code>NULL</code> 的情况和数据库实现有关。实际设计时，如果字段必须唯一且必须存在，建议同时写：</p>
<pre><code>phone char(11) not null unique
</code></pre>
<hr />
<h2>4.4 PRIMARY KEY 主键约束</h2>
<p>主键特点：</p>
<ol>
<li>唯一；</li>
<li>非空；</li>
<li>一张表通常只有一个主键；</li>
<li>可以由一个字段组成，也可以由多个字段组成。</li>
</ol>
<h3>4.4.1 单字段主键</h3>
<pre><code>create table student (
    id bigint primary key auto_increment,
    name varchar(20) not null
);
</code></pre>
<p><strong>为什么要主键？</strong></p>
<p>主键是每行数据的唯一身份。没有主键时，修改或删除某一行数据可能不精准。</p>
<h3>4.4.2 联合主键</h3>
<pre><code>create table score (
    student_id bigint,
    course_id bigint,
    score decimal(5,2),
    primary key(student_id, course_id)
);
</code></pre>
<p><strong>原因：</strong></p>
<p>一个学生在一门课程中只能有一条成绩记录，所以 <code>(student_id, course_id)</code> 组合不能重复。</p>
<hr />
<h2>4.5 AUTO_INCREMENT 自增</h2>
<p>示例：</p>
<pre><code>create table student (
    id bigint primary key auto_increment,
    name varchar(20) not null
);
</code></pre>
<p>插入数据：</p>
<pre><code>insert into student(name) values('张三');
insert into student(name) values('李四');
</code></pre>
<p><code>id</code> 会自动生成 1、2、3...</p>
<p><strong>注意：</strong></p>
<ol>
<li>自增字段必须是索引，通常是主键；</li>
<li>一张表只能有一个自增字段；</li>
<li>删除数据后，自增值通常不会自动回退；</li>
<li><code>truncate</code> 通常会重置自增计数。</li>
</ol>
<hr />
<h2>4.6 FOREIGN KEY 外键约束</h2>
<p>外键用于维护两张表之间的关系。</p>
<p>示例：班级表和学生表。</p>
<p>constraint_user</p>
<p><strong>原因：</strong></p>
<p><code>student.class_id</code> 引用 <code>class.id</code>。有外键后，学生不能随便填一个不存在的班级编号。</p>
<h3>4.6.1 外键带来的限制</h3>
<p>如果 <code>class</code> 中没有 <code>id = 100</code> 的班级，下面插入会失败：</p>
<pre><code>insert into student(name, class_id) values('张三', 100);
</code></pre>
<p>如果某个班级已经被学生引用，直接删除班级可能失败：</p>
<pre><code>delete from class where id = 1;
</code></pre>
<p><strong>原因：</strong></p>
<p>数据库要防止学生表中出现“找不到班级”的脏数据。</p>
<h3>4.6.2 外键动作</h3>
<p>常见动作：</p>
<table>
<thead>
<tr>
<th>动作</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>restrict</code></td>
<td>默认限制，不允许破坏关系</td>
</tr>
<tr>
<td><code>cascade</code></td>
<td>级联操作，父表更新/删除时子表跟着更新/删除</td>
</tr>
<tr>
<td><code>set null</code></td>
<td>父表删除后，子表外键设为 NULL</td>
</tr>
</tbody>
</table>
<p>示例：</p>
<pre><code>constraint fk_student_class
foreign key(class_id) references class(id)
on delete set null
on update cascade
</code></pre>
<p><strong>注意：</strong></p>
<p>真实项目中很多团队不使用数据库物理外键，而是在业务代码中维护关系。初学阶段仍然要理解外键原理。</p>
<hr />
<h2>4.7 CHECK 检查约束</h2>
<p>示例：</p>
<pre><code>create table student (
    id bigint primary key auto_increment,
    name varchar(20) not null,
    age int check(age &gt;= 0 and age &lt;= 150),
    gender tinyint check(gender in (0, 1, 2))
);
</code></pre>
<p><strong>原因：</strong></p>
<p>限制字段值必须在合理范围内，比如年龄不能是负数。</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>数据库系统概论期末复习：数据模型、关系理论与 SQL</title>
    <link href="https://blog.syrena.cn/posts/database-systems/database-systems/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/database-systems/database-systems/</id>
    <published>2026-06-06T00:00:00.000Z</published>
    <updated>2026-06-06T00:00:00.000Z</updated>
    <summary>专攻SQL知识点，快速掌握考点.</summary>
    <content type="html"><![CDATA[<h1>Note 01 绪论</h1>
<h2>1.1 数据库系统概述</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据库的 4 个基本概念&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;数据&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;data&lt;/font&gt;：数据是数据库中存储的基本对象。描述事物的符号记录称为数据。需经数字化处理。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;数据库&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;DataBase&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;，&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;DB&lt;/font&gt;：数据库是<strong>长期储存在计算机内、有组织的、可共享</strong>的大量数据的集合。</p>
<p>库中的数据按一定的数据模型组织、描述和储存，具有较小的<strong>冗余度</strong>&lt;font style="color:#F38F39;"&gt;redundancy&lt;/font&gt;、 高的<strong>数据独立性</strong>&lt;font style="color:#F38F39;"&gt;data&lt;/font&gt;&lt;font style="color:#F38F39;"&gt; &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;independency&lt;/font&gt; 和<strong>易扩展性</strong>&lt;font style="color:#F38F39;"&gt;scalability&lt;/font&gt;，并可各种用户共享。</p>
<p>数据库数据具有<strong>永久存储、有组织和可共享</strong>三个基本特点。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;数据库管理系统 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;DataBase Management System, DBMS&lt;/font&gt;：位于用户与操作系统之间的一层数据管理软件（计算机基础软件）</p>
<p>主要功能包括：</p>
<ol>
<li>数据定义功能</li>
</ol>
<p>数据库管理系统提供 <strong>数据定义语言</strong> &lt;font style="color:#F38F39;"&gt;Data Definition Language&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;， &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;DDL&lt;/font&gt;用户通过它可以方便地对数据库中的数据对象进行定义。</p>
<ol>
<li>数据组织、存储和管理</li>
</ol>
<p>数据库管理系统要分类组织、存储和管理各种数据，包括数据字典、用户数据、数据的存取路径等。</p>
<ol>
<li>数据操纵功能</li>
</ol>
<p>数据库管理系统还提供 <strong>数据操纵语言</strong> &lt;font style="color:#F38F39;"&gt;Data Manipulation Language, DML&lt;/font&gt;，用户可以使用它操纵数据，实现对数据库的基本操作，如查询、插入、删除和修改等。</p>
<ol>
<li>数据库的事务管理和运行管理</li>
<li>数据库的建立和维护功能</li>
<li>其他功能</li>
</ol>
<p>&lt;font style="color:#2F4BDA;"&gt;数据库系统&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;DataBase System, DBS&lt;/font&gt;：数据库系统是由数据库、数据库管理系统（及其应用开发工具）、应用程序和数据库管理员 &lt;font style="color:#F38F39;"&gt;DataBase Administrator, DBA&lt;/font&gt; 组成的存储、管理、处理和维护数据的系统。</p>
<p><img src="./Database-Systems0.png" alt="" /></p>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据管理技术的产生和发展&lt;/font&gt;</h3>
<p>数据管理技术经历了⼈工管理、文件系统、数据库系统 3 个阶段。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;人工管理阶段&lt;/font&gt;</h4>
<p>&lt;font style="color:#F1A2AB;"&gt;特点&lt;/font&gt;：数据不保存：应用程序管理数据；数据不共享；数据不具有独立性。</p>
<p>&lt;font style="color:#07787E;"&gt;缺点&lt;/font&gt;：数据的逻辑结构或物理结构发生变化后， 必须对应用程序做相应的修改，这就加重了程序员的负担。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;文件系统阶段&lt;/font&gt;</h4>
<p>&lt;font style="color:#F1A2AB;"&gt;特点&lt;/font&gt;：数据可以长期保存；由文件系统管理数据。</p>
<p>&lt;font style="color:#07787E;"&gt;缺点&lt;/font&gt;：数据共享性差，冗余度大；数据独立性差。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;数据库系统阶段&lt;/font&gt;</h4>
<p>&lt;font style="color:#F1A2AB;"&gt;特点&lt;/font&gt;：数据结构化；数据的共享性高，冗余度低，易扩充；数据独立性高；数据由 DBMS 统一管理和控制。</p>
<p><img src="./Database-Systems1.png" alt="" /></p>
<h2>1.2 数据模型</h2>
<p><strong>数据模型</strong> &lt;font style="color:#F38F39;"&gt;data model&lt;/font&gt; 是一种模型，是对现实世界数据特征的抽象。是对现实世界的模拟。</p>
<p>用来描述数据、组织数据和对数据进行操作的。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;两类数据模型&lt;/font&gt;</h3>
<p>数据模型应满足三方面要求：能比较真实地模拟现实世界；容易为人所理解；便于在计算机上实现。</p>
<p>根据模型应用的不同目的，可以将这些模型划分为两大类，它们分别属于两个不同的层次。</p>
<p>第一类是概念模型，第二类是逻辑模型和物理模型。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;概念模型 / 信息模型 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;conceptual model&lt;/font&gt;</h4>
<p>按用户的观点来对数据和信息建模，主要用于数据库设计。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;逻辑模型 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;logical model&lt;/font&gt;&lt;font style="color:#2F4BDA;"&gt; &lt;/font&gt;</p>
<p>包括层次模型 &lt;font style="color:#F38F39;"&gt;hierarchical model&lt;/font&gt;、⽹状模型 &lt;font style="color:#F38F39;"&gt;network model&lt;/font&gt;、 关系模型 &lt;font style="color:#F38F39;"&gt;relational model&lt;/font&gt; 面向对象数据模型 &lt;font style="color:#F38F39;"&gt;object oriented data model&lt;/font&gt; 和对象关系数据模型 &lt;font style="color:#F38F39;"&gt;object relational data model&lt;/font&gt; 半结构化数据模型 &lt;font style="color:#F38F39;"&gt;semistructured data model&lt;/font&gt; 等。</p>
<p>它是按计算机系统的观点对数据建模，主要用于数据库管理系统的实现。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;物理模型 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;logical model&lt;/font&gt;</p>
<p>对数据最底层的抽象，它描述数据在系统内部的表示方式和存取方法，或在磁盘或磁带上的存储方式和存取方法， 是面向计算机系统的。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;概念模型&lt;/font&gt;</h3>
<p><strong>概念模型是现实世界到机器世界的一个中间层次</strong>，表现为：</p>
<p>概念模型用于信息世界的建模；现实世界到信息世界的第一层抽象；</p>
<p>数据库设计⼈员进行数据库设计的有⼒工具；数据库设计⼈员和用户之间进行交流的语言。</p>
<p><strong>概念模型要求</strong>：</p>
<p>具有较强的语义表达能⼒；能够方便、直接地表达应用中的各种语义知识；简单、清晰、易于用户理解。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;信息世界中的基本概念&lt;/font&gt;</h4>
<p><strong>实体</strong> &lt;font style="color:#F38F39;"&gt;entity&lt;/font&gt;：客观存在并可相互区别的事物称为实体。</p>
<p><strong>属性</strong> &lt;font style="color:#F38F39;"&gt;attribute&lt;/font&gt;：实体所具有的某一特性称为属性。一个实体可以由若干个属性来刻画。</p>
<p><strong>码</strong> &lt;font style="color:#F38F39;"&gt;key&lt;/font&gt;：唯一标识实体的属性集称为码。</p>
<p><strong>域</strong> &lt;font style="color:#F38F39;"&gt;domain&lt;/font&gt;：域是一组具有相同数据类型的值的集合。属性的取指范围来自某个域。</p>
<p><strong>实体型</strong> &lt;font style="color:#F38F39;"&gt;entity type&lt;/font&gt;：用实体名及其属性名集合来抽象和刻画同类实体，称为实体型。</p>
<p><strong>实体集</strong> &lt;font style="color:#F38F39;"&gt;entity set&lt;/font&gt;：同一类型实体的集合称为实体集。</p>
<p><strong>联系</strong> &lt;font style="color:#F38F39;"&gt;relationship&lt;/font&gt;：实体之间的联系通常是指不同实体集之间的联系。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;实体型之间的联系：&lt;/font&gt;实体之间的联系有一对一&lt;font style="color:#F38F39;"&gt;1:1&lt;/font&gt;、一对多&lt;font style="color:#F38F39;"&gt;1:n&lt;/font&gt; 和多对多&lt;font style="color:#F38F39;"&gt;m:n&lt;/font&gt; 等多种类型。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;概念模型的一种表示方法：实体-联系方法 / E-R 图&lt;/font&gt;</p>
<p>E-R图提供了表示实体型、属性和联系的方法。</p>
<p>实体型：用矩形表示，框内写明实体名。</p>
<p>属性：用椭圆形表示，并用无向边将其与相应的实体型连接起来。</p>
<p>联系：用菱形表示，框内写明联系名，并用无向边分别与有关实体型连接起来，同时在无向边旁标上联系的类型。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据模型的组成要素&lt;/font&gt;</h3>
<p><strong>数据模型通常由数据结构、数据操作和完整性约束三部分组成。</strong> 数据结构：描述数据库的组成对象以及对象之间的联系。</p>
<p>&lt;font style="color:rgb(0,0,255);"&gt;数据结构&lt;/font&gt;：描述数据库的组成对象以及对象之间的联系。</p>
<p>数据结构是所描述的对象类型的集合，是对系统静态特性的描述。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;数据操作：&lt;/font&gt;数据操作是指对数据库中各种对象的实例允许执行的操作的集合，包括操作及有关的操作规则。</p>
<p>数据库主要有查询和更新（包括插入，删除、修改）两大类操作。是对系统动态特性的描述。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;数据的完整性约束条件：&lt;/font&gt;数据的完整性约束条件是一组完整性规则。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;常用的数据模型&lt;/font&gt;</h3>
<p>层次模型 &lt;font style="color:#F38F39;"&gt;hierarchical model&lt;/font&gt;、⽹状模型 &lt;font style="color:#F38F39;"&gt;network model&lt;/font&gt;、 关系模型 &lt;font style="color:#F38F39;"&gt;relational model&lt;/font&gt; 面向对象数据模型 &lt;font style="color:#F38F39;"&gt;object oriented data model&lt;/font&gt; 和对象关系数据模型 &lt;font style="color:#F38F39;"&gt;object relational data model&lt;/font&gt;</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;层次模型&lt;/font&gt;</h3>
<p>待补充</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;⽹状模型&lt;/font&gt;</h3>
<p>待补充</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;关系模型&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;关系模型的数据结构&lt;/font&gt;</h4>
<p><strong>关系</strong> &lt;font style="color:#F38F39;"&gt;relation&lt;/font&gt;：一个关系对应通常说的一张表。</p>
<p><strong>元组</strong> &lt;font style="color:#F38F39;"&gt;tuple&lt;/font&gt;：表中的一行即为一个元组。</p>
<p><strong>属性</strong> &lt;font style="color:#F38F39;"&gt;attribute&lt;/font&gt;：表中的一列即为一个属性，给每一个属性起一个名称即属性名。</p>
<p><strong>码</strong> &lt;font style="color:#F38F39;"&gt;key&lt;/font&gt;：也称为码键。表中的某个属性组，它可以唯一确定一个元组。</p>
<p><strong>域</strong> &lt;font style="color:#F38F39;"&gt;domain&lt;/font&gt;：域是一组具有相同数据类型的值的集合。属性的取值范围来自某个域。</p>
<p><strong>分量</strong>：元组中的一个属性值。</p>
<p><strong>关系模式</strong>：对关系的描述，一般表示为 [ 关系名（属性 1，属性 2，⋯，属性）]</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;关系模型的数据操纵与完整性约束&lt;/font&gt;</h4>
<p>关系模型的数据操纵主要包括查询、插入、删除和更新数据。这些操作必须满足关系的完整性约束条件。</p>
<p>关系的完整性约束条件包括三大类：<strong>实体完整性、参照完整性和用户定义的完整性</strong>。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;关系模型的优缺点&lt;/font&gt;</h4>
<p>&lt;font style="color:#E4495B;"&gt;特点&lt;/font&gt;：</p>
<ol>
<li>关系模型与格式化模型不同，它是建立在严格的数学概念的基础上的。</li>
<li>关系模型的概念单一。</li>
<li>存取路径对用户透明，具有更高数据独立性、更好安全保密性，简化程序员和数据库开发建立的工作。</li>
</ol>
<p>&lt;font style="color:#07787E;"&gt;缺点&lt;/font&gt;：</p>
<ol>
<li>存取路径对用户是隐蔽的，查询效率往往不如格式化数据模型。</li>
<li>为提高性能须对用户的查询请求进行优化，增加了开发数据库管理系统的难度。</li>
</ol>
<h2>1.3 数据库系统的结构</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据库系统模式的概念&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;模式 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;schema&lt;/font&gt;：数据库中全体数据的逻辑结构和特征的描述，它仅仅涉及型的描述， 不涉及具体的值。</p>
<p>模式的一个具体值称模式的一个 <strong>实例</strong> &lt;font style="color:#F38F39;"&gt;instance&lt;/font&gt;。同一个模式可以有很多实例。</p>
<p>模式是相对稳定的，而实例是相对变动的。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据库系统的三级模式结构&lt;/font&gt;</h3>
<p>数据库系统的三级模式结构是指数据库系统是由 &lt;font style="color:#2F4BDA;"&gt;外模式、模式和内模式&lt;/font&gt; 三级构成。</p>
<p><img src="./Database-Systems2.png" alt="" /></p>
<h4>&lt;font style="color:#2F4BDA;"&gt;模式 / 逻辑模式 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;schema&lt;/font&gt;</h4>
<p>数据库中全体数据的逻辑结构和特征的描述，是所有用户的公共数据视图。</p>
<p>是数据库系统模式结构的中间层。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;外模式 / 子模式 / 用户模式 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;external schema / subschema&lt;/font&gt;&lt;font style="color:#2F4BDA;"&gt; &lt;/font&gt;是数据库用户（包括应用程序员和最终用户）能够看⻅和使用的局部数据的逻辑结构和特征的描述， 是数据库用户的数据视图， 是与某一应用有关的数据的逻辑表示。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;内模式 / 存储模式 / 物理模式 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;internal schema / storage schema&lt;/font&gt; 一个数据库只有一个内模式。它是数据物理结构和存储方式的描述，是数据在数据库内部的组织方式。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据库的二级映像功能与数据独立性&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;外模式 / 模式 映像&lt;/font&gt;</h4>
<p>定义了该外模式与模式之间的对应关系。这些映像定义通常包含在各自外模式的描述中。</p>
<p>当模式改变时（例如增加新的关系、新的属性、改变属性的数据类型等），由数据库管理员对各个外模式 / 模式的 映像作相应改变，可以使外模式保持不变。应用程序是依据数据的外模式编写的，从而应用程序不必修改，保证了 数据与程序的逻辑独立性，简称数据的&lt;font style="color:#2F4BDA;"&gt; 逻辑独立性。&lt;/font&gt;</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;模式 / 内模式 映像&lt;/font&gt;</h4>
<p>定义了数据全局逻辑结构与存储结构之间的对应关系。</p>
<p>当数据库的存储结构改变时（例如选用了另一种存储结构），由数据库管理员对模式 / 内模式映像作相应改变，可 以使模式保持不变，从而应用程序也不必改变。保证了数据与程序的物理独立性，简称数据的&lt;font style="color:#2F4BDA;"&gt; 物理独立性。&lt;/font&gt;</p>
<p>数据与程序之间的独立性使得数据的定义和描述可以从应用程序中分离出去。</p>
<p>由于数据的存取由数据库管理系统管理，从而简化了应用程序的编制，大大减少了应用程序的维护和修改。</p>
<h2>1.4 数据库系统的组成</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;硬件平台及数据库&lt;/font&gt;</h3>
<p><strong>硬件资源要求</strong>：</p>
<ol>
<li>&lt;font style="color:#2F4BDA;"&gt;足够大的内存，&lt;/font&gt;用于存放操作系统、数据库管理系统的核心模块、数据缓冲区和应用程序。</li>
<li>&lt;font style="color:#2F4BDA;"&gt;足够大的磁盘或磁盘阵列等设备&lt;/font&gt;，用于存放数据库，有足够大的磁带（或光盘）作数据备份。</li>
<li>&lt;font style="color:#2F4BDA;"&gt;系统有较高的通道能⼒&lt;/font&gt;，以提高数据传送率。</li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;软件&lt;/font&gt;</h3>
<p><strong>数据库系统的软件主要包括</strong>：</p>
<ol>
<li>数据库管理系统。数据库管理系统是为数据库的建立、使用和维护配置的系统软件。</li>
<li>支持数据库管理系统运行的操作系统。</li>
<li>具有与数据库接口的高级语言及其编译系统，便于开发应用程序。</li>
<li>以数据库管理系统为核心的应用开发工具。</li>
<li>为特定应用环境开发的数据库应用系统。</li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;⼈员&lt;/font&gt;</h3>
<p>开发、管理和使用数据库系统的⼈员主要包括：</p>
<p>数据库管理员 &lt;font style="color:#F38F39;"&gt;DataBase Administrator, DBA&lt;/font&gt;、系统分析员和数据库设计⼈员、应用程序员和最终用户。</p>
<p>不同的⼈员涉及不同的数据抽象级别，具有不同的数据视图。</p>
<p><img src="./Database-Systems3.png" alt="" /></p>
<h1>Note 02 关系数据库</h1>
<h2>2.1 关系数据结构及形式化定义</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;关系&lt;/font&gt;</h3>
<p>单一的数据结构 &lt;font style="color:#2F4BDA;"&gt;关系&lt;/font&gt;：现实世界的实体以及实体间的各种联系均用关系来表示。</p>
<p>逻辑结构 &lt;font style="color:#2F4BDA;"&gt;⼆维表&lt;/font&gt;：从用户⻆度，关系模型中数据的逻辑结构是一张⼆维表。</p>
<p>关系模型是建立在集合代数的基础上。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;域 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;domain&lt;/font&gt;：域是一组具有相同数据类型的值的集合。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;笛卡⼉积&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;cartesian product&lt;/font&gt;：笛卡⼉积是域上的一种集合运算。</p>
<p><strong>给定一组域 D1, D2, ..., Dn，允许其中某些域是相同的，D1, D2, ..., Dn 的笛卡尔积为：</strong></p>
<p><strong>D1 × D2 × ... × Dn = {(d1, d2, ..., dn) | di ∈ Di, i = 1, 2, ..., n}</strong></p>
<p>&lt;font style="color:#2F4BDA;"&gt;元组 / n 元组 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;tuple / n-tuple&lt;/font&gt;：笛卡尔积中的每一个元素 <strong>(d₁, d₂, ..., dₙ)</strong>。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;分量 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;component&lt;/font&gt;：元素中的每一个值<strong>dᵢ</strong>。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;基数&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;cardinal number&lt;/font&gt;：一个域允许的不同取值个数。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;关系 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;relation&lt;/font&gt;： <strong>D₁ × D₂ × ... × Dₙ 的子集叫做在域 D₁, D₂, ..., Dₙ 上的关系，表示为 R(D₁, D₂, ..., Dₙ)</strong>。</p>
<p>R表示关系名， n表示关系的 &lt;font style="color:#2F4BDA;"&gt;目或度&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;Degree&lt;/font&gt;。关系中的每个元素是关系中的元组，通常用t表示。</p>
<p>当n=1时，称该关系为 &lt;font style="color:#2F4BDA;"&gt;单元关系 / 一元关系&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;unary relation&lt;/font&gt;，</p>
<p>当n=2时，称该关系为 &lt;font style="color:#213BC0;"&gt;⼆元关系 &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;binary relation&lt;/font&gt;。</p>
<p>关系也是一个⼆维表，表的每行对应一个元组，表的每列对应一个域。</p>
<p>每列的名字称为&lt;font style="color:#213BC0;"&gt; 属性&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;attribute&lt;/font&gt;，n目关系必有n个属性。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;候选码&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;candidate key&lt;/font&gt;：关系中的某一属性组的值能唯一地标识一个元组，其子集不能，则该属性组为候选码。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;全码&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;all key&lt;/font&gt;：在最极端的情况下，关系模式的所有属性都是这个关系模式的候选码。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;主码&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;primary key&lt;/font&gt;：若一个关系有多个候选码，则选定其中一个为主码。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;主属性&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;prime attribute&lt;/font&gt;：候选码的诸属性称为主属性。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;非主属性 / 非码属性&lt;/font&gt; &lt;font style="color:#F38F39;"&gt;non-prime attribute / non-key attribute&lt;/font&gt;：不包含在任何候选码中的属性。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;关系的三种类型&lt;/font&gt;：</p>
<ol>
<li>&lt;font style="color:#F1A2AB;"&gt;基本关系 / 基本表 / 基表&lt;/font&gt;：实际存在的表，是实际存储数据的逻辑表示。</li>
<li>&lt;font style="color:#F1A2AB;"&gt;查询表&lt;/font&gt;：查询结果对应的表。</li>
<li>&lt;font style="color:#F1A2AB;"&gt;视图表&lt;/font&gt;：由基本表或其他视图表导出的表，是虚表，不对应实际存储的数据。</li>
</ol>
<p>&lt;font style="color:#2F4BDA;"&gt;基本关系的性质：&lt;/font&gt;</p>
<ol>
<li>列是同质的 &lt;font style="color:rgb(233,105,0);"&gt;Homogeneous&lt;/font&gt;：每一列中的分量是同一类型的数据，来自同一个域（如同 int）。</li>
<li>不同的列可出自同一个域，称其中的每一列为一个属性，不同的属性要给予不同的属性名。</li>
<li>列的顺序无所谓，即列的次序可以任意交换。</li>
<li>任意两个元组的候选码不能取相同的值。</li>
<li>行的顺序无所谓，即行的次序可以任意交换。</li>
<li>分量必须取原子值，即每一个分量都必须是不可分的数据项。</li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;关系模式&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;关系模式&lt;/font&gt; &lt;font style="color:rgb(233,105,0);"&gt;relation schema&lt;/font&gt;：关系模式是对关系的描述，关系模式是型，关系是值。</p>
<ol>
<li>元组集合的结构：属性构成、属性来自的域属性与域之间的映象关系</li>
<li>一个关系通常由赋予它的元组语义确定。</li>
<li>现实的世界中还存在着完整性约束，</li>
</ol>
<p><strong>关系模式可以形式化地表示为</strong> <em><strong>R(U, D, DOM, F)</strong></em><strong>。</strong></p>
<p><strong>R：关系名，用于标识该关系的名称。</strong></p>
<p><strong>U：组成该关系的属性名集合，即关系中所有字段的名称列表。</strong></p>
<p><strong>D：属性组 U 中各个属性所来自的域，即每个属性取值的数据范围或类型集合。</strong></p>
<p><strong>DOM：属性向域的映射集合，描述每个属性具体对应哪一个域。</strong></p>
<p><strong>F：属性间的数据依赖关系集合，通常指函数依赖、多值依赖等约束条件，用于刻画属性之间的逻辑关联。</strong></p>
<h4>关系模式与关系</h4>
<p>关系模式是静态的、稳定的。</p>
<p>关系是动态的、随时间不断变化的。</p>
<p>关系是关系模式在某一时刻的状态或内容。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;关系数据库&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;关系数据库：&lt;/font&gt;在一个给定的应用领域中，所有关系的集合构成一个关系数据库。</p>
<h4>关系数据库的型与值</h4>
<ol>
<li>关系数据库的型：关系数据库模式，对关系数据库的描述。</li>
<li>关系数据库模式：若干域的定义，在这些域上定义的若干关系模式。</li>
<li>关系数据库的值：关系模式在某一时刻对应的关系的集合，简称为关系数据库。</li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;关系模型的存储结构&lt;/font&gt;</h3>
<p>在关系数据库的物理组织中</p>
<p>有的 DBMS 中一个表对应一个操作系统⽂件，将物理数据组织交给操作系统完成；</p>
<p>有的 DBMS 从操作系统那⾥申请若干个大的文件，自己划分文件空间，组织表、索引等存储结构，并进行存储管理。</p>
<h2>2.2 关系操作</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;基本的关系操作&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;基本的关系操作&lt;/font&gt;</h4>
<p>包括 &lt;font style="color:#2F4BDA;"&gt;查询&lt;/font&gt; 和 &lt;font style="color:#2F4BDA;"&gt;数据更新&lt;/font&gt; 两大部分 查询：可分为<strong>选择</strong>&lt;font style="color:#F38F39;"&gt;select&lt;/font&gt;、<strong>投影</strong>&lt;font style="color:#F38F39;"&gt;project&lt;/font&gt;、<strong>连接</strong>&lt;font style="color:#F38F39;"&gt;join&lt;/font&gt;、<strong>除</strong>&lt;font style="color:#F38F39;"&gt;divide&lt;/font&gt;、<strong>并</strong>&lt;font style="color:#F38F39;"&gt;union&lt;/font&gt;、<strong>差</strong>&lt;font style="color:#F38F39;"&gt;except&lt;/font&gt;、<strong>交</strong> &lt;font style="color:#F38F39;"&gt;intersection&lt;/font&gt;、 <strong>笛卡⼉积</strong>等。</p>
<p>其中，<strong>选择、投影、并、差、笛卡尔积是</strong> <strong>5</strong> <strong>种基本操作。</strong></p>
<p>&lt;font style="color:#2F4BDA;"&gt;数据更新&lt;/font&gt;：<strong>插入</strong>&lt;font style="color:#F38F39;"&gt;insert&lt;/font&gt;、<strong>删除</strong> &lt;font style="color:#F38F39;"&gt;delete&lt;/font&gt;、 <strong>修改</strong>&lt;font style="color:#F38F39;"&gt;update&lt;/font&gt;。</p>
<p>查询的表达能⼒是其中最主要的部分。</p>
<p>关系操作的特点是集合操作方式，即：操作的对象和结果都是集合。</p>
<p>这种操作方式也称为 <strong>一次一集合</strong>&lt;font style="color:#F38F39;"&gt;set-at-a-time&lt;/font&gt; 的方式。</p>
<p>相应地，非关系数据模型的数据操作方式则为 <strong>一次一记录</strong>&lt;font style="color:#F38F39;"&gt;record-at-a-time&lt;/font&gt; 的方式。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;关系数据语言的分类&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;关系代数语言：&lt;/font&gt;</p>
<p>用对关系的运算来表达查询要求。代表：ISBL。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;关系演算语言：&lt;/font&gt;</p>
<p>用谓词来表达查询要求。</p>
<ol>
<li><strong>元组关系演算语言</strong>：谓词变元的基本对象是元组变量，代表：APLHA，QUEL。</li>
<li><strong>域关系演算语言</strong>：谓词变元的基本对象是域变量，代表：QBE。</li>
</ol>
<p>&lt;font style="color:#2F4BDA;"&gt;具有关系代数和关系演算双重特点的语言：&lt;/font&gt;</p>
<p>代表: SQL &lt;font style="color:#F38F39;"&gt;Structured Query Language&lt;/font&gt;</p>
<h2>2.3 关系的完整性</h2>
<p>&lt;font style="color:#2F4BDA;"&gt;关系模型中有三类完整性约束：&lt;/font&gt;</p>
<ol>
<li><strong>实体完整性</strong> &lt;font style="color:#F38F39;"&gt;entity integrity&lt;/font&gt;</li>
<li><strong>参照完整性</strong> &lt;font style="color:#F38F39;"&gt;referential integrity&lt;/font&gt;</li>
<li><strong>用户定义的完整性</strong>&lt;font style="color:#F38F39;"&gt;user-defined integrity&lt;/font&gt;</li>
</ol>
<p>&lt;font style="color:#2F4BDA;"&gt;关系的两个不变性：&lt;/font&gt;实体完整性和参照完整性是关系模型必须满足的完整性约束条件。应该由关系系统自动支持。</p>
<p>用户定义的完整性是应用领域需要遵循的约束条件，体现了具体领域中的语义约束。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;实体完整性&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;规则：&lt;/font&gt;<strong>若属性（指一个或一组属性）</strong> <strong>是基本关系</strong> <strong>的主属性，</strong> <strong>则</strong> <strong>不能取空值</strong>&lt;font style="color:#F38F39;"&gt;null value&lt;/font&gt;<strong>。</strong></p>
<ol>
<li>实体完整性规则是针对基本关系而言的。一个基本表通常对应现实世界的一个实体集。</li>
<li>现实世界中的实体是可区分的，即它们具具有某种唯一性标识。</li>
<li>关系模型中以主码作为唯一性标识。</li>
<li>主码中的属性即主属性不能取空值。</li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;参照完整性&lt;/font&gt;</h3>
<p>现实世界中的实体之间往往存在某种联系，在关系模型中实体及实体间的联系都是用关系来描述的，这样就自然存 在着关系与关系间的引用。</p>
<p>主码用下划线标识。</p>
<p>&lt;font style="color:#F1A2AB;"&gt;外码&lt;/font&gt;：设 是基本关系 的一个或一组属性，但不是关系 的码， 是基本关系 的主码。如果 与 相 对应，则称 是 的 <strong>外码</strong> &lt;font style="color:#F38F39;"&gt;foreign key&lt;/font&gt;。</p>
<p>并称基本关系 为 <strong>参照关系</strong> <code>referencing relation</code>，基本关系 为被 <strong>参照关系</strong> <strong>/</strong> <strong>目标关系</strong>&lt;font style="color:#F38F39;"&gt;referenced&lt;/font&gt;&lt;font style="color:#F38F39;"&gt; &lt;/font&gt;&lt;font style="color:#F38F39;"&gt;relation / target relation&lt;/font&gt;。关系 R 和 S 不一定是不同的关系。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;规则&lt;/font&gt;：<strong>若属性（或属性组）</strong> <strong>是基本关系</strong> <strong>的外码，它与基本关系</strong> <strong>的主码</strong> <strong>相对应（基本关系</strong> <strong>和</strong> <strong>不一</strong> <strong>定是不同的关系），则对于</strong> <strong>中每个元组在</strong> <strong>上的值必须：</strong></p>
<ol>
<li><strong>或者取空值（</strong> F <strong>的每个属性值均为空值）；</strong></li>
<li><strong>或者等于</strong> S <strong>中某个元组的主码值。</strong></li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;用户定义的完整性&lt;/font&gt;</h3>
<p>&lt;font style="color:#2F4BDA;"&gt;规则：&lt;/font&gt;<strong>针对某一具体关系数据库的约束条件，它反映某一具体应用所涉及的数据必须满足的语义要求。</strong> 关系模型应提供定义和检验这类完整性的机制，以便用统一的系统的方法处理它们， 而不需由应用程序承担这一 功能。</p>
<h2>2.4 关系代数</h2>
<p><strong>关系代数是一种抽象的查询语言，用对关系的运算来表达查询。</strong> 关系代数的运算对象是关系，运算结果也是关系。</p>
<p>关系代数按运算符的不同可分为 门&lt;font style="color:#213BC0;"&gt;传统的集合运算 &lt;/font&gt;和 &lt;font style="color:#213BC0;"&gt;专⻔的关系运算&lt;/font&gt; 两类。</p>
<p>集合运算是从关系的⽔平方向即行的⻆度进行，专门的关系运算不仅涉及行而且涉及列。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;传统的集合运算&lt;/font&gt;</h3>
<p><strong>传统的集合运算是⼆目运算，包括并、差、交、笛卡⼉积</strong> <strong>4</strong> <strong>种运算。</strong></p>
<p><strong>设关系 R 和关系 S 具有相同的目 n（即两个关系都有 n 个属性），且相应的属性取自同一个域，t 是元组变量，t ∈ R 表示 t 是 R 的一个元组。</strong></p>
<p>&lt;font style="color:#213BC0;"&gt;并 &lt;/font&gt;&lt;font style="color:#C75C00;"&gt;union&lt;/font&gt;<strong>：R ∪ S = { t | t ∈ R ∨ t ∈ S }<br />
其结果仍为 n 目关系，由属于 R 或属于 S 的元组组成。</strong></p>
<p>&lt;font style="color:#213BC0;"&gt;差 &lt;/font&gt;&lt;font style="color:#C75C00;"&gt;except&lt;/font&gt;<strong>：R - S = { t | t ∈ R ∧ t ∉ S }<br />
其结果关系仍为 n 目关系，由属于 R 而不属于 S 的所有元组组成。</strong></p>
<p>&lt;font style="color:#213BC0;"&gt;交&lt;/font&gt;&lt;font style="color:#C75C00;"&gt; &lt;/font&gt;&lt;font style="color:#C75C00;"&gt;intersection&lt;/font&gt;<strong>：R ∩ S = { t | t ∈ R ∧ t ∈ S } ， R ∩ S = R - (R - S)<br />
其结果关系仍为 n 目关系，由既属于 R 又属于 S 的所有元组组成。</strong></p>
<p>&lt;font style="color:#213BC0;"&gt;笛卡尔积&lt;/font&gt;&lt;font style="color:#C75C00;"&gt; &lt;/font&gt;&lt;font style="color:#C75C00;"&gt;cartesian product&lt;/font&gt;<strong>：R × S = { trts | tr ∈ R ∧ ts ∈ S }<br />
R：n 目关系，k1 个元组，S：m 目关系，k2 个元组。<br />
运算结果为：行为 k1 × k2 个元组，列为 (n + m) 列元组的集合。<br />
其中元组的前 n 列是关系 R 的一个元组，后 m 列是关系 S 的一个元组。</strong></p>
<p><img src="./Database-Systems4.png" alt="" /></p>
<h3>&lt;font style="color:#DF2A3F;"&gt;专门的关系运算&lt;/font&gt;</h3>
<p><strong>关系运算包括：选择、投影、连接、除运算。</strong></p>
<p><img src="./Database-Systems32.png" alt="" /></p>
<h1>Note 03 关系数据库标准语言 SQL</h1>
<h2>3.1 SQL 概述</h2>
<p><strong>SQL</strong> &lt;font style="color:#C75C00;"&gt;Structured Query Language&lt;/font&gt; <strong>结构化查询语言</strong>，是关系数据库的标准语言。</p>
<p>SQL 是一个通用的、功能极强的关系数据库语言。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;SQL 的产生与发展&lt;/font&gt;</h3>
<p><img src="./Database-Systems5.png" alt="" /></p>
<h3>&lt;font style="color:#DF2A3F;"&gt;SQL 的特点&lt;/font&gt;</h3>
<h4>&lt;font style="color:#213BC0;"&gt;综合统一&lt;/font&gt;</h4>
<ol>
<li>集数据定义语言&lt;font style="color:#C75C00;"&gt;DDL&lt;/font&gt;，数据操纵语言&lt;font style="color:#C75C00;"&gt;DML&lt;/font&gt;，数据控制语言&lt;font style="color:#C75C00;"&gt;DCL&lt;/font&gt; 功能于一体。</li>
<li>可以独立完成数据库生命周期中的全部活动：定义关系模式，插入数据，建立数据库；对数据库中数据进行</li>
</ol>
<p>行查询和更新；数据库重构和维护；数据库安全性、完整性控制等。</p>
<ol>
<li>用户数据库投入运行后，可根据需要随时逐步修改模式，不影响数据的运行。</li>
<li>数据操作符统一。</li>
</ol>
<h4>&lt;font style="color:#213BC0;"&gt;高度非过程化&lt;/font&gt;</h4>
<p>用 SQL 进行数据操作时，只要提出 “做什么”，无须指明 “怎么做”，因此无须了解存取路径。</p>
<p>存取路径的选择以及 SQL 的操作过程由系统自动完成。</p>
<h4>&lt;font style="color:#213BC0;"&gt;面向集合的操作方式&lt;/font&gt;</h4>
<p>SQL 采用集合操作方式 不仅操作对象、查找结果可以是元组的集合；而且一次插入、删除、更新操作的对象也可以是元组的集合。</p>
<h4>&lt;font style="color:#213BC0;"&gt;以同一种语法结构提供多种使用方式&lt;/font&gt;</h4>
<p>SQL 是独立的语言：能够独立地用于联机交互的使用方式。</p>
<p>SQL ⼜是嵌入式语言：SQL 能够嵌入到高级语言（例如C，C++，Java）程序中，供程序员设计程序时使用。</p>
<p><strong>&lt;font style="color:#213BC0;"&gt;语言简洁，易学易用&lt;/font&gt;</strong></p>
<p>SQL 功能极强，完成核心功能只用了 9 个动词。</p>
<table>
<thead>
<tr>
<th><strong>SQL 功能</strong></th>
<th><strong>动词</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>数据查询</td>
<td>SELECT</td>
</tr>
<tr>
<td>数据定义</td>
<td>CREATE, DROP, ALTER</td>
</tr>
<tr>
<td>数据操纵</td>
<td>INSERT, UPDATE, DELETE</td>
</tr>
<tr>
<td>数据控制</td>
<td>GRANT, REVOKE</td>
</tr>
</tbody>
</table>
<p>&lt;font style="color:rgb(70,130,180);"&gt;缺点&lt;/font&gt;：SQL 只包含定义和操作命令，不包含控制流命令，如 IF...THEN、DO...WHILE。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;SQL 的基本概念&lt;/font&gt;</h3>
<p>SQL 支持关系数据库三级模式结构。</p>
<p>其中外模式包括若干 &lt;font style="color:#213BC0;"&gt;视图&lt;/font&gt; &lt;font style="color:#C75C00;"&gt;view&lt;/font&gt; 和部分 &lt;font style="color:#213BC0;"&gt;基本表 &lt;/font&gt;&lt;font style="color:#C75C00;"&gt;base table&lt;/font&gt;，数据库模式包括若干基本表， 内模式包括若干 &lt;font style="color:#213BC0;"&gt;存储文件&lt;/font&gt; &lt;font style="color:#C75C00;"&gt;stored file&lt;/font&gt;。</p>
<p><img src="./Database-Systems6.png" alt="" /></p>
<h4>&lt;font style="color:#213BC0;"&gt;基本表&lt;/font&gt;</h4>
<p>本身独立存在的表，关系数据库管理系统中一个关系就对应一个基本表。</p>
<p>一个或多个基本表对应一个存储文件，一个表可以带若干索引，索引也存放在存储文件中。</p>
<h4>&lt;font style="color:#213BC0;"&gt;存储文件&lt;/font&gt;</h4>
<p>逻辑结构组成了关系数据库的内模式。存储文件的物理结构对最终用户是隐蔽的。</p>
<h4>&lt;font style="color:#213BC0;"&gt;视图&lt;/font&gt;</h4>
<p>从一个或几个基本表导出的表。</p>
<p>本身不独立存储在数据库中，即数据库中只存放视图的定义而不存放视图对应的数据。</p>
<p>数据存放在导出视图的基本表中，因此视图是一个虚表。</p>
<p>视图在概念上与基本表等同，用户可以在视图上再定义视图。</p>
<h2>3.2 学生-课程数据库</h2>
<p><strong>学生</strong>-<strong>课程模式S-T ，学生</strong>-<strong>课程数据库中包括以下三个表：：</strong></p>
<p>&lt;font style="color:#213BC0;"&gt;学生表：Student(Sno,Sname,Ssex,Sage, Sdept)&lt;/font&gt;</p>
<p><img src="./Database-Systems7.png" alt="" /></p>
<p>&lt;font style="color:#213BC0;"&gt;课程表：Course(Cno, Cname, Cpno, Ccredit)&lt;/font&gt;</p>
<p><img src="./Database-Systems8.png" alt="" /></p>
<p>&lt;font style="color:#213BC0;"&gt;学生选课表：SC(Sno.Cno,Grade)&lt;/font&gt;</p>
<p><img src="./Database-Systems9.png" alt="" /></p>
<h2>3.3 数据定义</h2>
<p><strong>SQL</strong> <strong>的数据定义功能包括模式定义、表定义、视图和索引的定义。</strong></p>
<p><img src="./Database-Systems10.png" alt="" /></p>
<p>一个关系数据库管理系统的 <strong>实例</strong> &lt;font style="color:#C75C00;"&gt;instance&lt;/font&gt; 中可以建立多个数据库，一个数据库中可以建立多个模式，一个模式 下通常包括多个表、视图和索引等数据库对象。</p>
<p><img src="./Database-Systems11.png" alt="" /></p>
<h3>&lt;font style="color:#DF2A3F;"&gt;模式的定义与删除&lt;/font&gt;</h3>
<h4>&lt;font style="color:#213BC0;"&gt;定义模式&lt;/font&gt;</h4>
<p>&lt;font style="color:#E746A4;"&gt;语句格式&lt;/font&gt;：【CREATE SCHEMA &lt; 模式名 &gt; AUTHORIZATION &lt; 用户名 &gt; ;】</p>
<pre><code>/*为用户 WANG 定义一个学生-课程模式 S-T*/
CREATE SCHEMA "S-T" AUTHORIZATION WANG;
</code></pre>
<p>若没有指定模式名，那么 &lt; 模式名 &gt; 隐含为 &lt; 用户名 &gt;。</p>
<pre><code>/*为用户 WANG 定义一个模式，模式名隐含为 WANG*/
CREATE SCHEMA AUTHORIZATION WANG;
</code></pre>
<p>在 CREATE SCHEMA 中可以接受 CREATE TABLE， CREATE VIEW 和 GRANT 子句。也就是说用户可以在创建模式的同 时在这个模式定义中进一步创建基本表、视图， 定义授权。</p>
<p>&lt;font style="color:#E746A4;"&gt;语句格式&lt;/font&gt;：【CREATE SCHEMA &lt; 模式名 &gt; AUTHORIZATION &lt; 用户名 &gt;</p>
<p>​                                [ &lt; 表定义子句&gt; | &lt; 视图定义子句&gt; | &lt;授权定义子句&gt; ] ;】</p>
<pre><code>/*为用户 ZHANG 创建一个模式 TEST，并且在其中定义一个表 TAB1*/
CREATE SCHEMA TEST AUTHORIZATION ZHANG 
CREATE TABLE TAB1(COL1 SMALLINT, //短整数（2字节）
                  COL2 INT, //长整数（4字节）
                  COL3 CHAR(20), //长度为20的定长字符串
                  COL4 NUMERIC(10,3), //定点数，由10位数字（不包括符号、⼩数点）组成，⼩数点后面有3位数
字
                  COL5 DECIMAL(5,2) //同 NUMERIC，定点数，由5位数字组成，⼩数点后面有2位数字
                  );
</code></pre>
<p>执行创建模式语句必须拥有 DBA 权限，或者 DBA 授予在 CREATE SCHEMA 的权限。</p>
<h4>&lt;font style="color:#213BC0;"&gt;删除模式&lt;/font&gt;</h4>
<p>&lt;font style="color:#E746A4;"&gt;语句格式&lt;/font&gt;：【DROP SCHEMA &lt; 模式名 &gt; &lt; CASCADE | RESTRICT &gt; ;】</p>
<pre><code>/*删除了模式 ZHANG，同时删除模式为 CASCADE ，该模式中已经定义的表 TAB1 也删除*/
DROP SCHEMA ZHANG CASCADE;
</code></pre>
<p>&lt;font style="color:#0C68CA;"&gt;说明&lt;/font&gt;：<strong>CASCADE</strong> <strong>和</strong> <strong>RESTRICT</strong> <strong>必须⼆选一。</strong></p>
<p><strong>级联</strong> &lt;font style="color:#C75C00;"&gt;CASCADE&lt;/font&gt;：删除模式的同时把该模式中所有的数据库对象全部删除。</p>
<p><strong>限制</strong> &lt;font style="color:#C75C00;"&gt;RESTRICT&lt;/font&gt;：如果该模式中定义了下属的数据库对象（如表、视图等），则拒绝该删除语句的执行。当该模 式中没有任何下属的对象时才能执行。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;基本表的定义、删除与修改&lt;/font&gt;</h3>
<h4>&lt;font style="color:#0C68CA;"&gt;定义基本表&lt;/font&gt;</h4>
<p>语句格式：【CREATE TABLE &lt; 表名 &gt; ( &lt; 列名 &gt;&lt; 数据类型 &gt;［ 列级完整性约束条件 ］                                                  					[, &lt; 列名 &gt; &lt; 数据类型 &gt; [ 列级完整性约束条件 ] ]</p>
<p>​                                        [, &lt; 表级完整性约東条件 &gt; ] ) ;】</p>
<pre><code>/*建立一个“学生”表 Student*/
CREATE TABLE Student (Sno CHAR(9) PRIMARY KEY,  /*列级完整性约束条件，Sno 是主码*/
                      Sname CHAR(20) UNIQUE,    /* Sname 取唯一值*/
                      Ssex CHAR(2),
                      Sage SMALLINT,
                      Sdept CHAR(20)
</code></pre>
<pre><code>/*建立一个“课程”表 Course*/
CREATE TABLE Course (Cno CHAR(4) PRIMARY KEY,   /*列级完整性约東条件，Cno 是主码*/
                     Cname CHAR(40) NOT NULL,   /*列级完整性约束条件，Cname 不能取空值*/
                     Cpno CHAR(4),              /* Cpno 的含义是先修课*/
                     Ccredit SMALLINT,
                     FOREIGN KEY (Cpno) REFERENCES Course(Cno)
                                   /*表级完整性约束条件，Cpno是外码，被参照表是 Course，被参照列是Cno*/
                    );                          /*参照表和被参照表可以是同一个表*/
/*建立学生选课表 SC*/
CREATE TABLE SC(Sno CHAR(9)，
                Cno CHAR(4)，
                Grade SMALLINT，
                PRIMARY KEY （Sno，Cno），/*主码由两个属性构成，必须作为表级完整性进行定义*/
                FOREIGN KEY (Sno) REFERENCES Student(Sno)，
                                         /*表级完整性约束条件，Sno 是外码，被参照表是 Student */
                FOREIGN KEY (Cno) REFERENCES Course(Cno)
                                         /*表级完整性约束条件，Cno 是外码，被参照表是 Course */
               )；
</code></pre>
<h4>&lt;font style="color:#0C68CA;"&gt;数据类型&lt;/font&gt;</h4>
<p><img src="./Database-Systems12.png" alt="" /></p>
<p><img src="./Database-Systems13.png" alt="" /></p>
<h4>&lt;font style="color:#0C68CA;"&gt;模式与表&lt;/font&gt;</h4>
<p>每一个基本表都属于某一个模式，一个模式包含多个基本表。</p>
<p>当定义基本表时一般可以有三种方法定义它所属的模式。</p>
<p>方法一，在表名中明显地给出模式名。</p>
<pre><code>CREATE TABLE "S-T".Student (***);     /* Student 所属的模式是S-T*/
CREATE TABLE "S-T".Course(***);       /* Course 所属的模式是S-T*/
CREATE TABLE "S-T".SC(***);           /* SC 所属的模式是S-T*/
</code></pre>
<p>方法⼆，在创建模式语句中同时创建表。</p>
<p>方法三，设置所属的模式，这样在创建表时表名中不必给出模式名。</p>
<p>当用户创建基本表 / 数据库对象时若没有指定模式，系统根据 搜索路径 <code>search path</code> 来确定该对象所属的模式。</p>
<pre><code>/*显示当前的搜索路径*/
SHOW search_path;

/*搜索路径的当前默认值*/
$ user, PUBLIC;

/*数据库管理员可以设置搜索路径*/
SET search_path TO "S-T", PUBLIC;

/*然后，定义基本表时 DBMS 发现搜索路径中【第一个】模式名作为基本表所属模式(上述 SET 中第一个是"S-T")*/
CREATE TABLE Student (***);
</code></pre>
<h4>&lt;font style="color:#0C68CA;"&gt;修改基本表&lt;/font&gt;</h4>
<p>随着应用环境和应用需求的变化，有时需要修改已建立好的基本表。SQL 语言用 AITER TABLE 语句修改基本表。</p>
<p>语句格式：【ALTER TABLE &lt; 表名 &gt; [ &lt;font style="color:#601BDE;"&gt;ADD&lt;/font&gt; [ &lt;font style="color:#601BDE;"&gt;COLUMN &lt;/font&gt;] &lt; 新列名 &gt; &lt; 数据类型 &gt; [ 完整性约束 ] ]</p>
<p>​								[ ADD &lt; 表级完整性约束 &gt; ]</p>
<p>​								[ DROP [ COLUMN ] &lt; 列名 &gt; [ CASCADE | RESTRICT ] ]</p>
<p>​								[ DROP CONSTRAINT &lt; 完整性约束名 &gt; [ CASCADE | RESTRICT ] ]</p>
<p>​								[ ALTER COLUMN &lt; 列名 &gt; &lt; 数据类型 &gt; ] ;】</p>
<p>&lt;font style="color:#0C68CA;"&gt;说明&lt;/font&gt;：语句中 &lt; 表名 &gt; 是要修改的基本表。</p>
<p>&lt;font style="color:#C99103;"&gt;ADD&lt;/font&gt; 子句用于增加新列、新的列级完整性约束条件和新的表级完整性约束条件。</p>
<p>&lt;font style="color:#ED740C;"&gt;DROP COLUMN&lt;/font&gt; 子句用于删除表中的列。</p>
<p>&lt;font style="color:#ED740C;"&gt;DROP CONSTRAINT&lt;/font&gt; 子句用于删除指定的完整性约束条件。</p>
<p>&lt;font style="color:#ED740C;"&gt;ALTER COLUMN&lt;/font&gt; 子句用于修改原有的列定义，包括修改列名和数据类型。</p>
<p>两个 &lt;font style="color:#C99103;"&gt;DROP&lt;/font&gt; 语句中的删除模式不要求强制指定：在 MySQL 中，默认使用&lt;font style="color:#C99103;"&gt;RESTRICT&lt;/font&gt;。</p>
<pre><code>/*向 Student 表增加“入学时间”列，其数据类型为日期型。*/
ALTER TABLE Student ADD S_entrance DATE;

/*将年龄的数据类型由字符型（假设原来的数据类型是字符型）改为整数。*/
ALTER TABLE Student ALTER COLUMN Sage INT;

/*增加课程名称必须取唯一值的约束条件。*/
ALTER TABLE Course ADD UNIQUE(Cname);
</code></pre>
<h4>&lt;font style="color:#213BC0;"&gt;删除基本表&lt;/font&gt;</h4>
<p>当某个基本表不再需要时，可以使用 DROP TABLE 语句删除它。</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【DROP TABLE &lt; 表名 &gt; [ RESTRICT | CASCADE ] ;】</p>
<p>&lt;font style="color:#0C68CA;"&gt;说明&lt;/font&gt;：默认使用&lt;font style="color:#F38F39;"&gt;RESTRICT&lt;/font&gt;。</p>
<pre><code>/*删除 Student 表*/
DROP TABLE Student CASCADE;
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;索引的建立与删除&lt;/font&gt;</h3>
<p><strong>建立索引的目的</strong>：加快查询速度。</p>
<p><strong>谁可以建立索引</strong>：DBA 或表的属主（即建立表的⼈）。</p>
<p>DBMS 一般会自动建立以下列上的索引：&lt;font style="color:#F38F39;"&gt;PRIMARY KEY&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;；&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;UNIQUE&lt;/font&gt;&lt;font style="color:#F38F39;"&gt;；&lt;/font&gt;</p>
<p><strong>谁维护索引</strong>：DBMS 自动完成。</p>
<p><strong>使用索引</strong>：DBMS 自动选择是否使用索引及使用哪些索引。</p>
<h4>&lt;font style="color:#213BC0;"&gt;建立索引&lt;/font&gt;</h4>
<p>在 SQL 语言中，建立索引使用 CREATE INDEX 语句。</p>
<p>&lt;font style="color:#E4495B;"&gt;语句格式&lt;/font&gt;：</p>
<p>【CREATE [ UNIQUE ] [ CLUSTER ] INDEX &lt; 索引名 &gt; ON &lt; 表名 &gt; ( &lt; 列名 &gt; [ 次序 ]
[ &lt; 列名 &gt; [ 次序 ] ]
...) ;】</p>
<p>&lt;font style="color:#0C68CA;"&gt;说明&lt;/font&gt;：语句中 &lt; 表名 &gt; 是要建索引的基本表的名字。</p>
<p>索引可以建立在该表的一列或多列上，各列名之间用逗号分隔。</p>
<p>每个 &lt; 列名 &gt; 后面还可以用 &lt; 次序 &gt; 指定索引值的排列次序，可选 &lt;font style="color:#C75C00;"&gt;ASC&lt;/font&gt; <strong>升序</strong> 或 &lt;font style="color:#C75C00;"&gt;DESC&lt;/font&gt; <strong>降序</strong>，默认 &lt;font style="color:#C75C00;"&gt;ASC&lt;/font&gt;。</p>
<p>&lt;font style="color:#C75C00;"&gt;UNIQUE&lt;/font&gt; 表明此索引的每一个索引值只对应唯一的数据记录。</p>
<p>&lt;font style="color:#C75C00;"&gt;CLUSTER&lt;/font&gt; 表示要建立的索引是聚簇索引。聚簇索引是指索引顺序与表中记录的物理顺序一致的索引组织。</p>
<ol>
<li>在最经常查询的列上建立聚簇索引以提高查询效率；</li>
<li>一个基本表上最多只能建立一个聚簇索引；</li>
<li>经常更新的列不宜建立聚簇索引。</li>
</ol>
<pre><code>/*为 Student 表按学号升序建唯一索引*/
CREATE UNIQUE INDEX Stusno ON Student(Sno);

/*为 Course 表按课程号升序建唯一索引*/
CREATE UNIQUE INDEX Coucno ON Course(Cno);

/*为 SC 表按学号升序和课程号降序建唯一索引*/
CREATE UNIQUE INDEX SCno ON SC(Sno ASC,Cno DESC);

/*为 Student 表的 Sname 列建立一个聚簇索引*/
CREATE CLUSTER INDEX Stusname ON Student (Sname) ;
</code></pre>
<h4>&lt;font style="color:#213BC0;"&gt;修改索引&lt;/font&gt;</h4>
<p>对于已经建立的索引，如果需要对其重新命名，可以使用 ALTER INDEX 语句。</p>
<p>&lt;font style="color:#E4495B;"&gt;语句格式&lt;/font&gt;：【ALTER INDEX &lt; 旧索引名 &gt; RENAME TO &lt; 新索引名 &gt;；】</p>
<pre><code>/*将 SC 表的 SCno 索引名改为 SCSno */
ALTER INDEX SCno RENAME TO SCSno;
</code></pre>
<h4>&lt;font style="color:#213BC0;"&gt;删除索引&lt;/font&gt;</h4>
<p>在 SQL 中，删除索引使用 DROP INDEX 语句。</p>
<p>&lt;font style="color:#E4495B;"&gt;语句格式&lt;/font&gt;：【DROP INDEX &lt; 索引名 &gt; ;】</p>
<p>&lt;font style="color:#2F8EF4;"&gt;说明&lt;/font&gt;：删除索引时，系统会同时从数据字典中删去有关该索引的描述。</p>
<pre><code>/*删除 Student 表的 Stusname 索引*/
DROP INDEX Stusname;
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据字典&lt;/font&gt;</h3>
<p>数据字典是关系数据库管理系统内部的一组系统表。</p>
<p>数据字典记录了数据库中所有的定义信息，包括模式定义、 视图定义、索引定义、完整性约束定义、各类用户对 数据库的操作权限、统计信息等。</p>
<p>RDBMS 执行 SQL 数据定义时，实际就是更新数据字典。</p>
<h2>3.4 数据查询</h2>
<p>数据查询是数据库的核心操作。SQL 提供了 SELECT 语句进行数据查询。</p>
<p>&lt;font style="color:#E4495B;"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>​				&lt;font style="color:rgb(128,0,128);"&gt;                FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt; ] ... | ( &lt; &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;语句 &gt; ) [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>​				                [&lt;font style="color:rgb(128,0,128);"&gt; WHERE&lt;/font&gt; &lt; 条件表达式 &gt; ]</p>
<p>​				                [ &lt;font style="color:rgb(128,0,128);"&gt;GROUP BY&lt;/font&gt; &lt; 列名1 &gt; [ &lt;font style="color:rgb(128,0,128);"&gt;HAVING&lt;/font&gt; &lt; 条件表达式 &gt; ] ]</p>
<p>​				                [ &lt;font style="color:rgb(128,0,128);"&gt;ORDER BY&lt;/font&gt; &lt; 列名2 &gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ASC&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DESC&lt;/font&gt; ] ] ; 】</p>
<p>说明：根根据 &lt;font style="color:rgb(233,105,0);"&gt;WHERE&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;子句的条件表达式从 &lt;font style="color:rgb(233,105,0);"&gt;FROM&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;子句指定的基本表、视图或派生表中找出满足条件的元组，再按 &lt;font style="color:rgb(233,105,0);"&gt;SELECT&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;子句中的目标列表达式选出元组中的属性值形成结果表。</p>
<p>如果有 &lt;font style="color:rgb(233,105,0);"&gt;GROUP BY&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;子句，则将结果按 &lt; 列名1 &gt; 的值进行分组，该属性列值相等的元组为一个组。</p>
<p>通常会在每组中作用聚集函数。如果 &lt;font style="color:rgb(233,105,0);"&gt;GROUP BY&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;子句带 &lt;font style="color:rgb(233,105,0);"&gt;HAVING&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;短语，则只有满足指定条件的组才予以输出。</p>
<p>如果有 &lt;font style="color:rgb(233,105,0);"&gt;ORDER BY&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;子句，则结果表还要按 &lt; 列名2 &gt; 的值的升序或降序排序。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;单表查询&lt;/font&gt;</h3>
<p><strong>指仅涉及一个表的查询</strong>。</p>
<h4>&lt;font style="color:rgb(0,0,255);"&gt;选择表中的若⼲列&lt;/font&gt;</h4>
<p>&lt;font style="color:rgb(255,69,0);"&gt;查询指定列 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ... &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; ; 】</p>
<pre><code>/*查询全体学生的学号与姓名*/
SELECT Sno,Sname FROM Student；
/*查询全体学生的姓名、学号、所在系*/
SELECT Sname,Sno,Sdept FROM Student；
</code></pre>
<p>&lt;font style="color:rgb(255,69,0);"&gt;查询指定列 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;&lt;font style="color:rgb(255,69,0);"&gt;* &lt;/font&gt;&lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; ; 】</p>
<pre><code>/*查询全体学生的详细记录*/
SELECT * FROM Student；
</code></pre>
<p>&lt;font style="color:rgb(255,69,0);"&gt;查询经过计算的值 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ... &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：&lt; 目标列表达式 &gt; 可以为算术表达式、字符串常量、函数、列别名</p>
<pre><code>/*算术表达式：查询全体学⽣的姓名及其出⽣年份 出⽣年份 = 2004 - Sage */ 
SELECT Sname,2014-Sage FROM Student； 

/*字符串常量、函数：查询全体学⽣的姓名、出⽣年份和所在的院系，要求用⼩写字⺟表示系名。 
出⽣年份前使用字符串'Year of Birth:'标注，⼩写字⺟表示系名使用函数 LOWER() */ 
SELECT Sname,'Year of Birth:',2014-Sage,LOWER(Sdept) FROM Student； 

/*别名：查询全体学⽣的姓名、出⽣年份和所在的院系，要求用⼩写字⺟表示系名。通过指定别名来改变查询结果的列标题， 
别名可在原列名、算术表达式、字符串常量及函数后添加空格、在","前标注 */ 
SELECT Sname NAME,'Year of Birth:' BIRTH,2014-Sage BIRTHDAY,LOWER(Sdept) DEPARTMENT 
FROM Student;
</code></pre>
<p><img src="./Database-Systems14.png" alt="" /></p>
<p><img src="./Database-Systems15.png" alt="" /></p>
<p><img src="./Database-Systems16.png" alt="" /></p>
<h4>&lt;font style="color:rgb(0,0,255);"&gt;选择表中的若干元组 &lt;/font&gt;</h4>
<p>&lt;font style="color:rgb(255,69,0);"&gt;消除取值重复的行&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：</p>
<p>【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;&lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ... &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：如果没有指定 &lt;font style="color:rgb(233,105,0);"&gt;DISTINCT &lt;/font&gt;关键词，则默认为 &lt;font style="color:rgb(233,105,0);"&gt;ALL &lt;/font&gt;。</p>
<pre><code>/*查询选修了课程的学生学号，查询结果不重复*/
SELECT DISTINCT Sno FROM SC；
</code></pre>
<p>&lt;font style="color:rgb(255,69,0);"&gt;查询满足条件的元组 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>​				&lt;font style="color:rgb(128,0,128);"&gt;                	FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt; ] ... | ( &lt; &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;语句 &gt; ) [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>​			&lt;font style="color:rgb(128,0,128);"&gt;               	 	WHERE&lt;/font&gt; &lt; 条件表达式 &gt; ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：WHERE 子句查询满足指定条件的元组，查询条件谓词如下表，</p>
<p><strong>比较中</strong>， &lt;font style="color:rgb(233,105,0);"&gt;&lt;&gt; &lt;/font&gt;等同于 &lt;font style="color:rgb(233,105,0);"&gt;!= &lt;/font&gt;；</p>
<p><strong>确定范围中</strong>， &lt;font style="color:rgb(233,105,0);"&gt;BETWEEN &lt;/font&gt;后是范围的下限（即低值）， &lt;font style="color:rgb(233,105,0);"&gt;AND &lt;/font&gt;后是范围的上限（即高值）。</p>
<p><strong>字符匹配中</strong>，字符串的匹配 &lt;font style="color:rgb(219,112,147);"&gt;语法格式&lt;/font&gt;：【 [ &lt;font style="color:rgb(128,0,128);"&gt;NOT&lt;/font&gt; ] &lt;font style="color:rgb(128,0,128);"&gt;LIKE&lt;/font&gt; '&lt; 匹配串 &gt;' [ &lt;font style="color:rgb(128,0,128);"&gt;ESCAPE&lt;/font&gt; ' &lt; 换码字符 &gt; ' ]】</p>
<ol>
<li>&lt; 匹配串 &gt; 可以是一个完整的字符串，也可以含有通配符 &lt;font style="color:rgb(233,105,0);"&gt;% &lt;/font&gt;和 &lt;font style="color:rgb(233,105,0);"&gt;_ &lt;/font&gt;。</li>
<li>&lt;font style="color:rgb(233,105,0);"&gt;% &lt;/font&gt;：任意长度（长度可以为 0 ）的字符串。</li>
<li>&lt;font style="color:rgb(233,105,0);"&gt;_ &lt;/font&gt;：任意单个字符</li>
<li>如果 &lt;font style="color:rgb(233,105,0);"&gt;LIKE &lt;/font&gt;后面的匹配串中不含通配符，则可以用 &lt;font style="color:rgb(233,105,0);"&gt;= &lt;/font&gt;运算符取代 &lt;font style="color:rgb(233,105,0);"&gt;LIKE &lt;/font&gt;谓词，用 &lt;font style="color:rgb(233,105,0);"&gt;&lt;&gt; &lt;/font&gt;或 &lt;font style="color:rgb(233,105,0);"&gt;!= &lt;/font&gt;运算符取代 &lt;font style="color:rgb(233,105,0);"&gt;NOT LIKE &lt;/font&gt;谓词。</li>
<li>数据库字符集为 ASCII 时一个汉字需要两个 &lt;font style="color:rgb(233,105,0);"&gt;<em>，当字符集为 GBK（国家标准扩展编码）时只需要一个</em>&lt;/font&gt;&lt;font style="color:rgb(233,105,0);"&gt; &lt;/font&gt;。</li>
<li>如果用户要查询的字符串本身就含有通配符，则使用 ESCAPE ' &lt; 换码字符 &gt; ' 短语对通配符进行转义。 e.g. &lt;font style="color:rgb(233,105,0);"&gt;ESCAPE '' &lt;/font&gt;表示 &lt;font style="color:rgb(233,105,0);"&gt;\ &lt;/font&gt;为换码字符。匹配串中在 &lt;font style="color:rgb(233,105,0);"&gt;\ &lt;/font&gt;后面的字符不再具有通配符含义，转义为普通字符。</li>
</ol>
<p><strong>空值中</strong>， &lt;font style="color:rgb(233,105,0);"&gt;IS &lt;/font&gt;、 &lt;font style="color:rgb(233,105,0);"&gt;IS NOT &lt;/font&gt;不能使用 &lt;font style="color:rgb(233,105,0);"&gt;= &lt;/font&gt;、 &lt;font style="color:rgb(233,105,0);"&gt;&lt;&gt; &lt;/font&gt;或 &lt;font style="color:rgb(233,105,0);"&gt;!= &lt;/font&gt;代替。</p>
<p><strong>多重条件中</strong>，逻辑运算符 &lt;font style="color:rgb(233,105,0);"&gt;AND &lt;/font&gt;和 &lt;font style="color:rgb(233,105,0);"&gt;OR &lt;/font&gt;可用来连接多个查询条件。 &lt;font style="color:rgb(233,105,0);"&gt;AND &lt;/font&gt;优先级高于 &lt;font style="color:rgb(233,105,0);"&gt;OR &lt;/font&gt;，但可用括号改变。</p>
<p><img src="./Database-Systems17.png" alt="" /></p>
<pre><code>/*比较：查询考试成绩不及格的学⽣的学号*/ 
SELECT DISTINCT Sno FROM SC WHERE Grade&lt;60;  
WHERE Grade&lt;60; 
/*确定范围：查询年龄不在20～23岁之间的学⽣姓名、系别和年龄*/ 
SELECT Sname,Sdept,Sage FROM Student 
WHERE Sage NOT BETWEEN 20 AND 23; 

/*确定集合：查询计算机科学系[CS]、数学系[MA]和信息系[IS]学⽣的姓名和性别*/ 
SELECT Sname, Ssex FROM Student 
WHERE Sdept IN ('CS','MA','IS'); 

/*字符匹配.1：查询学号为 201215121 的学⽣的详细情况*/ 
SELECT * FROM Student 
WHERE Sno LIKE '201215121'; 

/*字符匹配.2：查询所有姓刘的学⽣的姓名、学号和性别*/ 
SELECT Sname,Sno,Ssex FROM Student 
WHERE Sname LIKE '刘%'; 

/*字符匹配.3：查询学号为 201215121 的学⽣的详细情况*/ 
SELECT * FROM Student 
WHERE Sno = '201215121'; 

/*字符匹配.4：查询姓“欧阳”且全名为三个汉字的学⽣的姓名*/ 
SELECT Sname FROM Student 
WHERE Sname LIKE '欧阳_'; /*ASCII*/ 
SELECT Sname FROM Student 
WHERE Sname LIKE '欧阳__'; /*GBK*/ 

/*字符匹配.5：查询以“DB_”开头，且倒数第三个字符为 i 的课程的详细情况，此处换码字符为'/' */ 
SELECT * FROM Course
          WHERE Cname LIKE 'DB/_%i__' ESCAPE '/'; 

/*空值：查询缺少成绩的学⽣的学号和相应的课程号*/ 
SELECT Sno,Cno FROM SC 
WHERE Grade IS NULL; 

/*多重条件：查询计算机科学系年龄在20岁以下的学⽣姓名*/ 
SELECT Sname FROM Student 
WHERE Sdept='CS' AND Sage&lt;20;
</code></pre>
<p>&lt;font style="color:rgb(0,0,255);"&gt;ORDER BY &lt;/font&gt;&lt;font style="color:rgb(0,0,255);"&gt;子句 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：</p>
<p>【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt; ] ... | ( &lt; &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;语句 &gt; ) [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>[&lt;font style="color:rgb(128,0,128);"&gt; WHERE&lt;/font&gt; &lt; 条件表达式 &gt; ]</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;ORDER BY&lt;/font&gt; &lt; 列名2 &gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ASC&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DESC&lt;/font&gt; ] ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：ORDER BY 子句对查询结果按照一个或多个属性列的升序 &lt;font style="color:rgb(233,105,0);"&gt;ASC &lt;/font&gt;或降序 &lt;font style="color:rgb(233,105,0);"&gt;DESC &lt;/font&gt;排列，默认值为升序。</p>
<p>当排序列含空值时：<strong>空值默认为最⼤值</strong>。 &lt;font style="color:rgb(233,105,0);"&gt;ASC &lt;/font&gt;空值的元组最后显示。 &lt;font style="color:rgb(233,105,0);"&gt;DESC &lt;/font&gt;空值的元组最先显示。</p>
<p>&lt;font style="color:rgb(0,0,255);"&gt;聚集函数 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：</p>
<p>【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;聚集函数&lt;/font&gt; ]</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;               FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt; ] ... | ( &lt; &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;语句 &gt; ) [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>[&lt;font style="color:rgb(128,0,128);"&gt; WHERE&lt;/font&gt; &lt; 条件表达式 &gt; ]</p>
<p>[ &lt;font style="color:rgb(128,0,128);"&gt;ORDER BY&lt;/font&gt; &lt; 列名2 &gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ASC&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DESC&lt;/font&gt; ] ] ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：SQL 提供的聚集函数主要如下，限制条件默认 &lt;font style="color:rgb(233,105,0);"&gt;ALL &lt;/font&gt;：</p>
<table>
<thead>
<tr>
<th><strong>聚集函数</strong></th>
<th><strong>意义</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>【COUNT(*)】</td>
<td>统计元组个数</td>
</tr>
<tr>
<td>【COUNT([ALL | DISTINCT] &lt;列名&gt;)】</td>
<td>统计一列中值的个数</td>
</tr>
<tr>
<td>【SUM([ALL | DISTINCT] &lt;列名&gt;)】</td>
<td>计算一列值的总和（此列必须是数值型）</td>
</tr>
<tr>
<td>【AVG([ALL | DISTINCT] &lt;列名&gt;)】</td>
<td>计算一列值的平均值（此列必须是数值型）</td>
</tr>
<tr>
<td>【MAX([ALL | DISTINCT] &lt;列名&gt;)】</td>
<td>求一列值中的最大值</td>
</tr>
<tr>
<td>【MIN([ALL | DISTINCT] &lt;列名&gt;)】</td>
<td>求一列值中的最小值</td>
</tr>
</tbody>
</table>
<pre><code>/*查询学⽣总⼈数*/ 
SELECT COUNT(*) FROM Student; 

/*查询选修了课程的学⽣⼈数*/ 
SELECT COUNT(DISTINCT Sno) FROM SC; 

/*计算选修 1 号课程的学⽣平均成绩*/ 
SELECT AVG(Grade) FROM SC WHERE Cno='1'; 

/*查询选修 1 号课程的学⽣最高分数*/ 
SELECT MAX(Grade) FROM SC WHERE Cno='1'; 

/*查询学⽣ 201215012 选修课程的总学分数*/ 
SELECT SUM(Ccredit) FROM SC, Course 
WHERE Sno='201215012' AND SC.Cno=Course.Cno;
</code></pre>
<p>&lt;font style="color:rgb(0,0,255);"&gt;GROUP BY &lt;/font&gt;&lt;font style="color:rgb(0,0,255);"&gt;子句 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：</p>
<p>【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt; ] ... | ( &lt; &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;语句 &gt; ) [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>[&lt;font style="color:rgb(128,0,128);"&gt; WHERE&lt;/font&gt; &lt; 条件表达式 &gt; ]</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;      GROUP BY&lt;/font&gt; &lt; 列名1 &gt; [ &lt;font style="color:rgb(128,0,128);"&gt;HAVING&lt;/font&gt; &lt; 条件表达式 &gt; ]</p>
<p>[ &lt;font style="color:rgb(128,0,128);"&gt;ORDER BY&lt;/font&gt; &lt; 列名2 &gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ASC&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DESC&lt;/font&gt; ] ] ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：GROUP BY 子句将查询结果按某一列或多列的值分组，值相等的为一组。</p>
<p>未分组时，聚集函数将作用于整个查询结果，<strong>分组后聚集函数将作用于每一个组</strong>，即每一组都有一个函数值。</p>
<p>&lt;font style="color:rgb(233,105,0);"&gt;HAVING &lt;/font&gt;短语指定筛选条件，对组进行筛选，最终只输出满足指定条件的组。</p>
<p>&lt;font style="color:rgb(233,105,0);"&gt;WHERE &lt;/font&gt;子句作用于基本表或视图，从中选择满足条件的元组。 &lt;font style="color:rgb(233,105,0);"&gt;HAVING &lt;/font&gt;短语作用于组，从中选择满足条件的组。</p>
<p>&lt;font style="color:rgb(233,105,0);"&gt;WHERE &lt;/font&gt;子句与 &lt;font style="color:rgb(233,105,0);"&gt;HAVING &lt;/font&gt;短语的<strong>作用对象不同</strong>， &lt;font style="color:rgb(233,105,0);"&gt;WHERE &lt;/font&gt;<strong>子句中不能用聚集函数作为条件表达式</strong>。</p>
<pre><code>/*求各个课程号及相应的选课⼈数*/ 
SELECT Cno, COUNT(Sno) FROM SC 
       GROUP BY Cno; 
       
/*查询选修了三⻔以上课程的学⽣学号*/ 
SELECT Sno FROM SC 
       GROUP BY Sno 
       HAVING COUNT(*) &gt;3; 
       
/*查询平均成绩⼤于等于 90 分的学⽣学号和平均成绩*/ 
SELECT Sno, AVG(Grade) FROM SC 
        GROUP BY Sno 
        HAVING AVG(Grade)&gt;=90;
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;连接查询&lt;/font&gt;</h3>
<p><strong>查询同时涉及两个以上的表</strong>。</p>
<p>&lt;font style="color:rgb(0,0,255);"&gt;等值与⾮等值连接查询 &lt;/font&gt;</p>
<p>连接查询的 WHERE 子句中用来连接两个表的条件称为 <strong>连接条件</strong> 或 <strong>连接谓词</strong>。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;连接格式&lt;/font&gt;：【 [ &lt; 表名1 &gt; .] &lt; 列名1 &gt; &lt; &lt;font style="color:rgb(255,69,0);"&gt;比较运算符&lt;/font&gt; &gt; [ &lt; 表名2 &gt; .] &lt; 列名2 &gt; 】</p>
<p>【 [ &lt; 表名1 &gt; .] &lt; 列名1 &gt; &lt;font style="color:rgb(128,0,128);"&gt;BETWEEN&lt;/font&gt; [ &lt; 表名2 &gt; .] &lt; 列名2 &gt; &lt;font style="color:rgb(128,0,128);"&gt;AND&lt;/font&gt; [ &lt; 表名3 &gt; .] &lt; 列名3 &gt; 】</p>
<p>当连接运算符为 &lt;font style="color:rgb(233,105,0);"&gt;= &lt;/font&gt;时，称为 <strong>等值连接</strong>。使用其他运算符称为 <strong>⾮等值连接</strong>。</p>
<p>连接谓词中的列名称为 <strong>连接字段</strong>。连接条件中的各连接字段类型必须是可比的，但名字不必相同。</p>
<p>连接操作的一种执行⽅法：<strong>嵌套循环法</strong> &lt;font style="color:rgb(233,105,0);"&gt;NESTED-LOOP &lt;/font&gt;。</p>
<pre><code>/*查询每个学⽣及其选修课程的情况*/ 
SELECT Student.*,SC.* /* '表名.*' 表示表中所有信息 */ 
        FROM Student,SC 
        WHERE Student.Sno=SC.Sno;
</code></pre>
<p><strong>自然连接</strong>：在等值连接中把目标列中重复的属性列去掉。</p>
<p>若属性名唯一，引用时可以去掉表名前缀；若一个属性在两个表中均出现，则引用时必须加上表名前缀。</p>
<p>一条 SQL 语句可以同时完成选择和连接查询，这时 WHERE 子句是由连接谓词和选择谓词组成的复合条件。</p>
<pre><code>/*查询选修 2 号课程且成绩在 90 分以上的所有学⽣的学号和姓名*/ 
SELECT Student.Sno,Sname,Ssex,Sage, Sdept, Cno, Grade 
        FROM Student,SC 
        WHERE Student.Sno=SC.Sno AND 
                    SC.Cno='2' AND SC.Grade&gt;90;
</code></pre>
<p>&lt;font style="color:rgb(0,0,255);"&gt;⾃身连接 &lt;/font&gt;</p>
<p>一个表与其⾃⼰进行连接。通过为其取两个别名加以区别，且必须使用别名前缀。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;连接格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名 &gt; &lt; 别名1 &gt; , &lt; 表名 &gt; &lt; 别名2 &gt; &lt;font style="color:rgb(128,0,128);"&gt;WHERE&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;连接条件&lt;/font&gt; ] 】</p>
<p>若只取一个别名，另一个用原名，则 SQL 引擎将无法区分表中的不同实例。</p>
<pre><code>/*查询每一⻔课的间接先修课（即先修课的先修课）*/ 
SELECT FIRSTC.Cno,SECONDC.Cpno 
        FROM Course FIRSTC,Course SECONDC /*MySQL 中 'FIRST' 是关键字，此处教材取别名为'FIRST'不合适*/ 
        WHERE FIRSTC.Cpno=SECONDC.Cno;
</code></pre>
<p>&lt;font style="color:rgb(0,0,255);"&gt;外连接 &lt;/font&gt;</p>
<p>MySQL 支持左外连接与右外连接。</p>
<p>但不直接支持全外连接。如需实现全外连接，可通过将左外连接和右外连接的结果集进行 UNION 操作实现。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;连接格式&lt;/font&gt;：<strong>左外连接</strong>：【 &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名1 &gt; &lt;font style="color:rgb(128,0,128);"&gt;LEFT OUTER JOIN&lt;/font&gt; &lt; 表名2 &gt; &lt;font style="color:rgb(128,0,128);"&gt;ON&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;连接条件&lt;/font&gt; ] 】</p>
<p><strong>右外连接</strong>：【 &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名1 &gt; &lt;font style="color:rgb(128,0,128);"&gt;RIGHT OUTER JOIN&lt;/font&gt; &lt; 表名2 &gt; &lt;font style="color:rgb(128,0,128);"&gt;ON&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;连接条件&lt;/font&gt; ] 】</p>
<p><strong>全外连接</strong>：【 &lt;font style="color:rgb(128,0,128);"&gt;FROM&lt;/font&gt; &lt; 表名1 &gt; &lt;font style="color:rgb(128,0,128);"&gt;LEFT OUTER JOIN&lt;/font&gt; &lt; 表名2 &gt; &lt;font style="color:rgb(128,0,128);"&gt;ON&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;连接条件&lt;/font&gt; ]</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                                            UNION&lt;/font&gt; &lt; 表名1 &gt; &lt;font style="color:rgb(128,0,128);"&gt;RIGHT OUTER JOIN&lt;/font&gt; &lt; 表名2 &gt; &lt;font style="color:rgb(128,0,128);"&gt;ON&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;连接条件&lt;/font&gt; ] 】</p>
<pre><code>/*查询每个学⽣及其选修课程的情况,左外连接实现*/ 
SELECT Student.Sno,Sname, Ssex,Sage,Sdept,Cno,Grade 
        FROM Student LEFT OUTER JOIN SC ON (Student.Sno=SC.Sno);
</code></pre>
<h4>&lt;font style="color:rgb(0,0,255);"&gt;多表连接 &lt;/font&gt;</h4>
<p>两个以上的表进行连接。</p>
<pre><code>/*查询每个学⽣的学号、姓名、选修的课程名及成绩*/ 
SELECT Student.Sno,Sname,Cname,Grade 
        FROM Student,SC,Course 
        WHERE Student.Sno=SC.Sno AND SC.Cno=Course.Cno;
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;嵌套查询&lt;/font&gt;</h3>
<p>在 SQL 语言中，一个 SELECT—FROM—WHERE 语句称为一个 <strong>查询块</strong>。</p>
<p><strong>嵌套查询</strong> &lt;font style="color:rgb(233,105,0);"&gt;nested query &lt;/font&gt;：将一个查询块嵌套在另一个查询块的 WHERE 子句或 HAVING 短语的条件中的查询。</p>
<p>上层的查询块称为 <strong>外层查询</strong> / <strong>⽗查询</strong>，下层查询块称为 <strong>内层查询</strong> / <strong>子查询</strong>。</p>
<p>子查询中不能使用 ORDER BY 子句（ ORDER BY 子句只能对最终查询结果排序）。</p>
<p>层层嵌套⽅式反映了 SQL 语言的结构化。</p>
<p>有些嵌套查询可以用连接运算替代，有些是不能替代的。</p>
<p>&lt;font style="color:rgb(0,0,255);"&gt;带有 IN 谓词的子查询&lt;/font&gt;</p>
<p>在嵌套查询中，子查询的结果往往是个集合，用 IN 谓词表示⽗查询的条件在子查询结果的集合中。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt; ] ... | ( &lt; &lt;font style="color:rgb(128,0,128);"&gt;SELECT &lt;/font&gt;语句 &gt; ) [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   WHERE&lt;/font&gt; &lt; 目标列表达式 &gt; &lt;font style="color:rgb(128,0,128);"&gt;IN&lt;/font&gt; ( [ &lt;font style="color:rgb(128,0,128);"&gt;子查询&lt;/font&gt; ] ) ; 】</p>
<pre><code>/*查询选修了课程名为“信息系统”的学⽣学号和姓名。⽅法一：嵌套查询*/ 
SELECT Sno,Sname 
        FROM Student 
        WHERE Sno IN 
                 (SELECT Sno 
                        FROM SC 
                        WHERE Cno IN 
                                 (SELECT Cno 
                                        FROM Course 
                                        WHERE Cname='信息系统' 
                                   ) 
                 ); 
/*查询选修了课程名为“信息系统”的学⽣学号和姓名。⽅法⼆：连接查询*/ 
SELECT Student. Sno,Sname 
        FROM Student,SC,Course 
        WHERE Student.Sno=SC.Sno AND 
                 SC.Cno=Course.Cno AND 
                 Course.Cname='信息系统';
</code></pre>
<p>如果子查询的查询条件不依赖于⽗查询，称为 <strong>不相关子查询</strong>。</p>
<p>如果子查询的查询条件依赖于⽗查询，称为 <strong>相关子查询</strong> &lt;font style="color:rgb(233,105,0);"&gt;correlated subquery &lt;/font&gt;，</p>
<p>整个查询语句称为 <strong>相关嵌套查询</strong> &lt;font style="color:rgb(233,105,0);"&gt;correlated nested query &lt;/font&gt;语句。</p>
<h4>&lt;font style="color:rgb(0,0,255);"&gt;带有比较运算符的子查询 &lt;/font&gt;</h4>
<p>确切知道内层查询返回的是单个值时，可以用比较运算符与子查询结果进行比较。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   WHERE&lt;/font&gt; &lt; 目标列表达式 &gt; &lt; &lt;font style="color:rgb(255,69,0);"&gt;比较运算符&lt;/font&gt; &gt; ( [ &lt;font style="color:rgb(128,0,128);"&gt;单返回值子查询&lt;/font&gt; ] ) ; 】</p>
<pre><code>/*找出每个学⽣超过他⾃⼰选修课程平均成绩的课程号*/ 
SELECT Sno,Cno 
        FROM SC x 
        WHERE Grade &gt;=(SELECT AVG(Grade) 
                                        FROM SC y 
                                        WHERE y.Sno=x. Sno);
</code></pre>
<h4>&lt;font style="color:rgb(0,0,255);"&gt;带有 ANY / SOME 或 ALL 谓词的子查询&lt;/font&gt;</h4>
<p>子查询返回多值时要用 ANY / SOME 或 ALL 谓词修饰符。使用 ANY 或 ALL 谓词时则必须同时使用比较运算符。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   WHERE&lt;/font&gt; &lt; 目标列表达式 &gt; &lt; &lt;font style="color:rgb(255,69,0);"&gt;比较运算符&lt;/font&gt; &gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;ANY&lt;/font&gt; ] ( [ &lt;font style="color:rgb(128,0,128);"&gt;多返回值子查询&lt;/font&gt; ] ) ; 】</p>
<pre><code>/*查询⾮计算机科学系中比计算机科学系所有学⽣年龄都⼩的学⽣姓名及年龄。⽅法一：多返回值子查询*/ 
SELECT Sname,Sage 
        FROM Student 
        WHERE Sage &lt; ALL 
                 (SELECT Sage FROM Student 
                           WHERE Sdept='CS') 
                           AND Sdept&lt;&gt;'CS': 
/*查询⾮计算机科学系中比计算机科学系所有学⽣年龄都⼩的学⽣姓名及年龄。⽅法⼆：聚集函数*/ 
SELECT Sname,Sage 
        FROM Student 
        WHERE Sage &lt; 
                 (SELECT MIN(Sage) 
                        FROM Student 
                        WHERE Sdept='CS') 
                        AND Sdept&lt;&gt;'CS';
</code></pre>
<p><strong>用聚集函数实现子查询通常比直接用</strong> <strong>ANY</strong> <strong>或 ALL</strong> <strong>查询效率要高</strong>。</p>
<p><img src="./Database-Systems18.png" alt="" /></p>
<h4>&lt;font style="color:rgb(0,0,255);"&gt;带有 EXISTS 谓词的子查询&lt;/font&gt;</h4>
<p>EXISTS 代表存在量词。带有 EXISTS 谓词的子查询不返回任何数据，只产生逻辑真值 “true” 或逻辑假值 “false”。</p>
<p>&lt;font style="color:rgb(219,112,147);"&gt;语句格式&lt;/font&gt;：【 &lt;font style="color:rgb(128,0,128);"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:rgb(255,69,0);"&gt;ALL&lt;/font&gt; | &lt;font style="color:rgb(255,69,0);"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ &lt;font style="color:rgb(128,0,128);"&gt;AS&lt;/font&gt; ] &lt; 别名 &gt;</p>
<p>&lt;font style="color:rgb(128,0,128);"&gt;                   WHERE&lt;/font&gt; [ &lt;font style="color:rgb(128,0,128);"&gt;NOT&lt;/font&gt; ] &lt;font style="color:rgb(128,0,128);"&gt;EXIETS&lt;/font&gt; ( [ &lt;font style="color:rgb(128,0,128);"&gt;子查询&lt;/font&gt; ] ) ; 】</p>
<p>&lt;font style="color:rgb(70,130,180);"&gt;说明&lt;/font&gt;：由 EXISTS 引出的子查询目标列表都用 &lt;font style="color:rgb(255,69,0);"&gt;* &lt;/font&gt;， 因为带 EXISTS 子查询只返回真值或假值，给出列名无实际意义。</p>
<p>&lt;font style="color:rgb(255,69,0);"&gt;( * 待复习 ) &lt;/font&gt;<strong>全称量词</strong> &lt;font style="color:rgb(233,105,0);"&gt;for all &lt;/font&gt;通过 <strong>(∀</strong><em><strong>x</strong></em><strong>)</strong><em><strong>P</strong></em><strong>≡¬(∃</strong><em><strong>x</strong></em><strong>)(¬</strong><em><strong>P</strong></em><strong>)</strong> 实现。</p>
<p>&lt;font style="color:rgb(255,69,0);"&gt;( * 待复习 ) &lt;/font&gt;<strong>蕴涵</strong> &lt;font style="color:rgb(233,105,0);"&gt;implication &lt;/font&gt;通过<strong>p→q≡¬p∨q</strong><em><strong>p</strong></em><strong>→</strong><em><strong>q</strong></em><strong>≡¬</strong><em><strong>p</strong></em><strong>∨</strong>_<strong>q</strong>_实现。</p>
<pre><code>/*查询所有选修了 1 号课程的学生姓名*/
SELECT Sname
    FROM Student
    WHERE EXISTS
        (SELECT *
            FROM SC
            WHERE Sno=Student.Sno AND Cno='1');
/*查询没有选修 1 号课程的学生姓名*/
SELECT Sname
    FROM Student
    WHERE NOT EXISTS
        (SELECT *
            FROM SC
            WHERE Sno=Student.Sno AND Cno='1');
/*查询选修了全部课程的学生姓名*/
SELECT Sname
    FROM Student
    WHERE NOT EXISTS
        (SELECT *
            FROM Course
            WHERE NOT EXISTS
                (SELECT *
                    FROM SC
                    WHERE Sno=Student.Sno
                    AND Cno=Course.Cno));
/*查询⾄少选修了学生 201215122 选修的全部课程的学生号码*/
SELECT DISTINCT Sno
    FROM SC SCX
    WHERE NOT EXISTS
    (SELECT *
        FROM SC SCY
        WHERE SCY.Sno=' 201215122 ' AND
           NOT EXISTS
           (SELECT *
                FROM SC SCZ
                WHERE SCZ.Sno=SCX.Sno AND
            SCZ.Cno=SCY.Cno));
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;集合查询&lt;/font&gt;</h3>
<p>SELECT 语句的查询结果是元组的集合，所以多个 SELECT 语句的结果可进行集合操作。</p>
<p>集合操作主要包括<strong>并操作</strong> <strong>UNION</strong>、<strong>交操作 INTERSECT</strong> 和<strong>差操作 EXCEPT</strong>。</p>
<p><strong>参加集合操作的各查询结果的列数必须相同，对应项的数据类型也必须相同。</strong></p>
<p>&lt;font style="color:#2F4BDA;"&gt;并操作 UNION | 交操作 INTERSECT | 差操作 EXCEPT&lt;/font&gt;</p>
<p>语句格式：</p>
<p>【[ &lt;font style="color:#601BDE;"&gt;SELECT&lt;/font&gt; 子句( 结尾无分号 ) ]
[ &lt;font style="color:#601BDE;"&gt;UNION&lt;/font&gt; [ &lt;font style="color:#601BDE;"&gt;ALL&lt;/font&gt; ] | &lt;font style="color:#601BDE;"&gt;INTERSECT&lt;/font&gt; | &lt;font style="color:#601BDE;"&gt;EXCEPT&lt;/font&gt; ]
[ &lt;font style="color:#601BDE;"&gt;SELECT &lt;/font&gt;子句 ] ;】</p>
<p>&lt;font style="color:#ED740C;"&gt;UNION&lt;/font&gt;：将多个查询结果合并起来，自动去掉重复元组。</p>
<p>&lt;font style="color:#ED740C;"&gt;UNION ALL&lt;/font&gt;：将多个查询结果合并起来时，保留重复元组。</p>
<pre><code>/*查询选修了课程1或者选修了课程2的学生*/
SELECT Sno
    FROM SC
    WHERE Cno='1'
UNION
SELECT Sno
    FROM SC
    WHERE Cno='2';
    
/*查询计算机科学系的学生与年龄不大于19岁的学生的交集*/
SELECT *
    FROM Student
    WHERE Sdept='CS'
INTERSECT
SELECT *
    FROM Student
    WHERE Sage&lt;=19;
    
/*查询计算机科学系的学生与年龄不大于19岁的学生的差集*/
SELECT *
    FROM Student
    WHERE Sdept='CS'
EXCEPT
SELECT *
    FROM Student
    WHERE Sage&lt;=19;
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;基于派生表的查询&lt;/font&gt;</h3>
<p>子查询不仅可以出现在 WHERE 子句中，还可以出现在 FROM 子句中， 这时子查询生成的临时 <strong>派生表</strong> <code>derived table</code> 成为主查询的查询对象。</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:#ED740C;"&gt;ALL&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;DISTINCT &lt;/font&gt;] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>FROM &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt;  ] ...  , &lt; SELECT 语句 &gt;  [ AS ] &lt; 别名 &gt;</p>
<p>[ WHERE &lt; 条件表达式 &gt; ]</p>
<p>[ GROUP BY &lt; 列名1 &gt;</p>
<p>[ HAVING &lt; 条件表达式 &gt; ] ]</p>
<p>[ ORDER BY &lt; 列名2 &gt; [ ASC | DESC ] ] ;】</p>
<p>说明：通过 FROM 子句生成派生表时，AS 关键字可以省略，但<strong>必须为派生关系指定一个别名</strong>。</p>
<pre><code>/*查询所有选修了1号课程的学生姓名*/
SELECT Sname
    FROM Student,(SELECT Sno FROM SC WHERE Cno='1') AS SC1
    WHERE Student.Sno=SC1.Sno;
</code></pre>
<p>&lt;font style="color:#DF2A3F;"&gt;SELECT 语句的一般格式&lt;/font&gt;</p>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;SELECT&lt;/font&gt; [ &lt;font style="color:#ED740C;"&gt;ALL&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;DISTINCT&lt;/font&gt; ] &lt; 目标列表达式 &gt; [ , &lt; 目标列表达式 &gt; ] ...</p>
<p>&lt;font style="color:#601BDE;"&gt;FROM&lt;/font&gt; &lt; 表名或视图名 &gt; [ , &lt; 表名或视图名 &gt;  ] ... | ( &lt; &lt;font style="color:#601BDE;"&gt;SELECT&lt;/font&gt; 语句 &gt; ) [ AS ] &lt; 别名 &gt;</p>
<p>[ &lt;font style="color:#601BDE;"&gt;WHERE&lt;/font&gt; &lt; 条件表达式 &gt; ]</p>
<p>[ &lt;font style="color:#601BDE;"&gt;GROUP BY&lt;/font&gt; &lt; 列名1 &gt;</p>
<p>[ &lt;font style="color:#601BDE;"&gt;HAVING&lt;/font&gt; &lt; 条件表达式 &gt; ] ]</p>
<p>[ &lt;font style="color:#601BDE;"&gt;ORDER BY&lt;/font&gt; &lt; 列名2 &gt; [ &lt;font style="color:#ED740C;"&gt;ASC&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;DESC&lt;/font&gt; ] ] ;】</p>
<p>&lt;font style="color:#ED740C;"&gt;( * 待复习 )&lt;/font&gt;</p>
<h2>3.5 数据更新</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;插入数据&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;插入元组&lt;/font&gt;</h4>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;INSERT INTO&lt;/font&gt; &lt; 表名 &gt; [ ( &lt; 属性列1 &gt; [, &lt; 属性列2 &gt; ] … ) ]</p>
<p>&lt;font style="color:#601BDE;"&gt;VALUES&lt;/font&gt; ( &lt; 常量1 &gt; [, &lt; 常量2 &gt; ] … ) ;】</p>
<p>说明：功能是将新元组插入指定表中。INTO 子句中没有出现的属性列，新元组在这些列上将取空值。</p>
<p>若在 INTO 子句中只指出表名，没有指出属性名：</p>
<p>表示新元组要在表的所有属性列上都指定值，属性列的次序与 CREATE TABLE 中的次序相同。</p>
<pre><code>/*将一个新学生元组（学号：201215128，姓名：陈冬，性别：男，所在系：IS，年龄：18岁）插入到 Student表中*/
INSERT INTO Student(Sno,Sname,Ssex,Sdept,Sage)
        VALUES ('201215128', 陈冬,'男','IS',18);
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;插入子查询结果&lt;/font&gt;</h4>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;INSERT INTO&lt;/font&gt; &lt; 表名 &gt; [ ( &lt; 属性列1 &gt; [, &lt; 属性列2 &gt; ] … ) ]</p>
<p>[ 子查询 ] ;】</p>
<pre><code>/*对每一个系，求学生的平均年龄，并把结果存入数据库*/
CREATE TABLE Dept_age (Sdept CHAR(15), Avg_age SMALLINT);  /*建立新表*/
INSERT INTO Dept_age(Sdept, Avg_age)  /*插入*/
       SELECT Sdept,AVG(Sage)
          FROM Student
          GROUP BY Sdept;
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;修改数据&lt;/font&gt;</h3>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;UPDATE&lt;/font&gt; &lt; 表名 &gt;</p>
<p>​				&lt;font style="color:#601BDE;"&gt;SET &lt;/font&gt;&lt; 列名 &gt; = &lt; 表达式 &gt; [ , &lt; 列名 &gt; = &lt; 表达式 &gt; ] ...</p>
<p>​				[ &lt;font style="color:#601BDE;"&gt;WHERE&lt;/font&gt; &lt; 条件 &gt; ] ;】</p>
<p>说明：修改操作⼜称更新操作，其功能是修改指定表中满足 WHERE 子句条件的元组。</p>
<p>SET 子句给出 &lt; 表达式 &gt; 的值用于取代相应的属性列值。如果省略 WHERE 子句，则表示要修改表中的所有元组。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;修改某一个元组的值&lt;/font&gt;</h4>
<pre><code>/*将学生 201215121 的年龄改 22 岁*/
UPDATE Student
    SET Sage=22
    WHERE Sno='201215121';
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;修改多个元组的值&lt;/font&gt;</h4>
<pre><code>/*将所有学生的年龄增加 1 岁*/
UPDATE Student
    SET Sage=Sage+1;
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;带子查询的修改语句&lt;/font&gt;</h4>
<pre><code>/*将计算机科学系全体学生的成绩置零*/
UPDATE SC
    SET Grade=0
    WHERE Sno IN
        (SELECT Sno
              FROM Student
              WHERE Sdept='CS');
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;删除数据&lt;/font&gt;</h3>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;DELETE FROM&lt;/font&gt; &lt; 表名 &gt;</p>
<p>​				[ &lt;font style="color:#601BDE;"&gt;WHERE &lt;/font&gt;&lt; 条件 &gt; ] ;】</p>
<p>说明：功能是删除指定表中满足 WHERE 子句条件的元组。</p>
<p>如果省略 WHERE 子句则表示删除表中全部元组，但表的定义仍在字典中。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;删除某一个元组的值&lt;/font&gt;</h4>
<pre><code>/*删除学号为 201215128 的学生记录*/
DELETE FROM Student
       WHERE Sno='201215128';
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;修改多个元组的值&lt;/font&gt;</h4>
<pre><code>/*删除所有的学生选课记录*/
DELETE FROM SC;
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;带子查询的修改语句&lt;/font&gt;</h4>
<pre><code>/*删除计算机科学系所有学生的选课记录*/
DELETE FROM SC
       WHERE Sno IN
          (SELECT Sno
                FROM Student
                WHERE Sdept='CS');
</code></pre>
<h2>3.6 空值的处理</h2>
<p>空值的存在是因为取值有不确定性，对关系运算带来特殊的问题，所以需要做特殊的处理。</p>
<p>SQL 语言中允许某些元组的某些属性取空值，一般有以下几种情况：</p>
<ol>
<li>该属性有值，但当前不知道它的具体值。</li>
<li>该属性不应该有值。</li>
<li>由于某种原因不便于填写。</li>
</ol>
<h4>&lt;font style="color:#2F4BDA;"&gt;空值的产生&lt;/font&gt;</h4>
<p>在插入时定义为空值，取空值；</p>
<p>插入语句中没有赋值的属性，其值为空值；</p>
<p>外连接也会产生空值，空值的关系运算也会产生空值。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;空值的判断&lt;/font&gt;</h4>
<p>判断一个属性的值是否为空值，用 &lt;font style="color:#601BDE;"&gt;IS NULL&lt;/font&gt; 或 &lt;font style="color:#601BDE;"&gt;IS NOT NULL&lt;/font&gt; 来表示。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;空值的约束条件&lt;/font&gt;</h4>
<ol>
<li>属性定义（或者域定义）中有 &lt;font style="color:#601BDE;"&gt;NOT NULL&lt;/font&gt; 约束条件的不能取空值</li>
<li>加了&lt;font style="color:#ED740C;"&gt; UNIQUE&lt;/font&gt; 限制的属性不能取空值</li>
<li>码属性不能取空值</li>
</ol>
<h4>&lt;font style="color:#2F4BDA;"&gt;空值的算术运算、比较运算和逻辑运算&lt;/font&gt;</h4>
<p>算术运算：空值与另一个值（包括另一个空值）的算术运算的结果为空值；</p>
<p>比较运算：空值与另一个值（包括另一个空值）的比较运算结果为 UNKNOWN；</p>
<p>逻辑运算：扩展成三值逻辑，如下：</p>
<p><img src="./Database-Systems19.png" alt="" /></p>
<h2>3.7 视图</h2>
<p><strong>视图是从一个或几个基本表（或视图）导出的表。它与基本表不同，是一个虚表。</strong> 数据库中只存放视图的定义，不存放视图对应的数据。</p>
<p>表中的数据发生变化，视图中查询出的数据也就随之改变。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;定义视图&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;建立视图&lt;/font&gt;</h4>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;CREATE VIEW&lt;/font&gt; &lt; 视图名 &gt; [ ( &lt; 列名 &gt; [ , &lt; 列名 &gt; ] ... ) ]</p>
<p>​				&lt;font style="color:#601BDE;"&gt;AS&lt;/font&gt; &lt; 子查询 &gt;</p>
<p>​				[ &lt;font style="color:#ED740C;"&gt;WITH CHECK OPTION&lt;/font&gt; ] ;】</p>
<p>说明：组成视图的属性列名：全部省略或全部指定。</p>
<p>子查询不允许含有 ORDER BY 子句和 DISTINCT 短语（教材：是否可以含有取决于具体系统的实现）。</p>
<p>RDBMS 执行 CREATE VIEW 语句时只是把视图定义存入当据字并不执行其中的 SELECT 语句。</p>
<p>在对视图查询时，按视图的定义从基本表中将数据查出。</p>
<p>&lt;font style="color:#ED740C;"&gt;WITH CHECK OPTION&lt;/font&gt;：对视图进行 UPDATE、INSERT 和 DELETE 操作时要保证操作后的行满足子查询条件表达式。</p>
<p>如果不满足，则拒绝更新操作。</p>
<p><strong>行列子集视图</strong>：若一个视图是从单个基本表导出的，并且只是去掉了基本表的某些行和某些列，但保留了主码。视图可以建立在一个或多个基本表和视图上。</p>
<p><strong>带虚拟列的视图</strong> <strong>/</strong> <strong>带表达式的视图</strong>：定义视图时可以根据应用的需要设置一些派生属性列，这些派生属性由于在基 本表中并不实际存在，也称它们为 <strong>虚拟列</strong>。</p>
<p><strong>分组视图</strong>：带有聚集函数和 GROUP BY 子句的查询来定义视图。</p>
<pre><code>/*建立信息系学生的视图，并要求进行修改和插入操作时仍需保证该视图只有信息系的学生,此视图为：行列子集视图*/
CREATE VIEW IS_Student
       AS SELECT Sno,Sname,Sage
              FROM Student
              WHERE Sdept='IS'
              WITH CHECK OPTION;
/*定义一个反映学生出生年份的视图，此视图为：带虚拟列的视图 / 带表达式的视图*/
CREATE VIEW BT_S(Sno,Sname,Sbirth)
       AS SELECT Sno,Sname,2014-Sage FROM Student;
       
/*将学生的学号及平均成绩定义为一个视图，此视图为：分组视图*/
CREATE VIEW S_G(Sno,Gavg)
       AS SELECT Sno,AVG(Grade)
              FROM SC
              GROUP BY Sno;
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;删除视图&lt;/font&gt;</h4>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;DROP VIEW&lt;/font&gt; &lt; 视图名 &gt; [ &lt;font style="color:#ED740C;"&gt;CASCADE&lt;/font&gt; ] ;】</p>
<p>删除基表时，由该基表导出的所有视图定义都必须显式地使用 DROP VIEW 语句删除。</p>
<pre><code>/*删除视图 BT_S*/
DROP VIEW BT_S;

/*级联删除视图 IS_S1*/
DROP VIEW IS IS_S1 CASCADE;
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;查询视图&lt;/font&gt;</h4>
<p>视图定义后，用户就可以像对基本表一样对视图进行查询了。</p>
<p><strong>视图消解</strong> &lt;font style="color:#ED740C;"&gt;view resolution&lt;/font&gt;：关系数据库管理系统执行对视图的查询时，⾸先进行有效性检查，检查查询中涉 及的表、视图等是否存在。如果存在，则从数据字典中取出视图的定义，把定义中的子查询和用户的查询结合起 来，转换成等价的对基本表的查询，然后再执行修正了的查询。</p>
<p>有些情况下，视图消解法不能生成正确查询，目前多数关系数据库系统对行列子集视图的查询均能进行正确转换。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;更新视图&lt;/font&gt;</h4>
<p>说明：更新视图是指通过视图来插入、删除和数据，因为视图不适宜存储数据，因此对视图的更新操作将通过视图 消解，转为对实际表的更新操作。</p>
<p>为防止在更新视图时出错，定义视图时要加上 &lt;font style="color:#ED740C;"&gt;WITH CHECK OPTION &lt;/font&gt;子句。</p>
<p><strong>更新视图的限制</strong>：</p>
<p>一些视图是不可更新的，因为对这些视图的更新不能唯一地有意义地转换成对相应基本表的更新。</p>
<p>一般地，行列子集视图是可更新的。除行列子集视图外，有些视图理论上是可更新的，还有些视图是不可更新的。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;视图的作用&lt;/font&gt;</h4>
<ol>
<li>视图能够简化用户的操作。</li>
<li>视图使用户能以多种⻆度看待同一数据。</li>
<li>视图对重构数据库提供了一定程度的逻辑独立性。</li>
<li>视图能够对机密数据提供安全保护。</li>
<li>适当的利用视图可以更清晰的表达查询。</li>
</ol>
<h1>Note 04 数据库安全性</h1>
<h2>4.1 数据库安全性概述</h2>
<p>数据库的一大特点是数据可以共享，但共享必然带来数据库的安全性问题，因此不能是无条件的共享。</p>
<p><strong>数据库的安全性</strong>：指保护数据库以防止不合法使用所造成的数据泄露、更改或破坏。</p>
<p>系统安全保护措施是否有效是数据库系统的主要技术指标之一。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;数据库的不安全因素&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;非授权用户对数据库的恶意存取和破坏&lt;/font&gt;</h4>
<p>一些黑客和犯罪分子在用户存取数据库时猎取用户名和用户口令，然后假冒合法用户偷取、修改或破坏用户数据。</p>
<p>因此，必须阻止有损数据库安全的非法操作，以保证数据免受未经授权的访问和破坏。</p>
<p>数据库管理系统提供的安全措施主要包括 <strong>用户身份鉴别、存取控制和视图</strong> 等技术。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;数据库中重要或敏感的数据被泄露&lt;/font&gt;</h4>
<p>黑客和敌对分子千方百计盗窃数据库中的重要数据，一些机密信息可能被暴露。</p>
<p>数据库管理系统提供的主要技术有 <strong>强制存取控制、数据加密存储和加密传输</strong> 等。</p>
<p>安全要求较高的系统提供 <strong>审计日志</strong> 分析。对潜在的威胁提前采取措施加以防范。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;安全环境的脆弱性&lt;/font&gt;</h4>
<p>数据库的安全性与计算机系统的安全性紧密联系，主要包括计算机硬件、操作系统、⽹络系统等的安全性。</p>
<p>在计算机安全方面需要建立一套 <strong>可信</strong> <code>Trusted</code> 计算机系统的概念和标准。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;安全标准简介&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;安全标准的发展历史&lt;/font&gt;</h4>
<p><img src="./Database-Systems20.png" alt="" /></p>
<p>&lt;font style="color:#2F4BDA;"&gt;TCSEC / TDI &lt;/font&gt;将系统划分为 4 组<code>division</code>，共 7 个等级。（用于设计前？）</p>
<p><img src="./Database-Systems21.png" alt="" /></p>
<p>D 级：最低级别。实现操作系统基本功能，无安全性保障。[ DOS ]</p>
<p>C1 级：能够实现对用户和数据的分离，进行自主存取控制 &lt;font style="color:#ED740C;"&gt;DAC&lt;/font&gt;，给不同用户分配不同权限。</p>
<p>C2 级：安全产品的最低档，细化 &lt;font style="color:#ED740C;"&gt;DAC&lt;/font&gt;，以个⼈身份注册负责，并实施审计和资源隔离。[ Windows2000, Oracle7 ]</p>
<p>B1 级：标记安全保护。对标记主客体实施强制存取控制 &lt;font style="color:#ED740C;"&gt;MAC&lt;/font&gt;，给不同文件加密级标签。 [ SQL Server ]</p>
<p>B2 级：结构化保护。建立形式化的安全策略模型。对所有主客体实施 <code>DAC</code> 和 <code>MAC</code>。</p>
<p>B3 级：安全域。访问监控器负责所有访问请求的授权决定，审计更强，提供系统恢复过程（数据库备份）。</p>
<p>A 级： 验证设计，给出系统的形式化设计说明和验证（确保每个部分的功能和行为都是明确的和可验证的）。</p>
<p>&lt;font style="color:#2F4BDA;"&gt;CC &lt;/font&gt;（用于设计后验证？）</p>
<p><img src="./Database-Systems22.png" alt="" /></p>
<h2>4.2 数据库安全性控制</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;概述&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;非法使用数据库的情况&lt;/font&gt;</h4>
<ol>
<li>编写合法程序绕过数据库管理系统及其授权机制。</li>
<li>直接或编写应用程序执行非授权操作。</li>
<li>通过多次合法查询数据库，从中推导出一些保密数据。</li>
</ol>
<p>&lt;font style="color:#2F4BDA;"&gt;计算机系统的安全模型&lt;/font&gt;：计算机系统中安全措施是一级一级层层设置的。</p>
<p><img src="./Database-Systems23.png" alt="" /></p>
<h4>&lt;font style="color:#2F4BDA;"&gt;数据库管理系统安全性控制模型&lt;/font&gt;</h4>
<ol>
<li>数据库管理系统对提出 SQL 访问请求的数据库用户进行身份鉴别，防止不可信用户使用系统；</li>
<li>在 SQL 处理层进行自主存取控制和强制存取控制，进一步还可以进行推理控制。</li>
<li>为监控恶意访问，可根据具体安全需求配置审计规则，对用户访问行为和系统关键操作进行审计。</li>
<li>通过设置简单入侵检测规则，对异常用户行为进行检测和处理。</li>
<li>在数据存储层，数据库管理系统不仅存放用户数据，还存储安全数据，提供存储加密功能等。</li>
</ol>
<p><img src="./Database-Systems24.png" alt="" /></p>
<h3>&lt;font style="color:#DF2A3F;"&gt;用户身份鉴别&lt;/font&gt;</h3>
<p>用户身份鉴别是数据库管理系统提供的最外层安全保护措施。每个用户在系统中都有个用户标识。</p>
<p>每个用户标识由 <strong>用户名</strong><code>user name</code> 和唯一 <strong>用户标识号</strong><code>UID</code> 两部分组成。</p>
<p>每次用户要求进入系统时，由系统进行核对，通过鉴定后才提供使用数据库管理系统的权限。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;用户身份鉴别的方法&lt;/font&gt;</h4>
<p><strong>静态口令鉴别</strong>：静态口令一般由用户自己设定，这些口令是静态不变的。</p>
<p><strong>动态口令鉴别</strong>，口令是动态变化的，每次鉴别时均需使用动态产生的新口令登录数据库管理系统，一次一密。</p>
<p><strong>生物特征鉴别</strong>：通过生物特征进行认证的技术，生物特征如指纹、虹膜和掌纹等。</p>
<p><strong>智能卡鉴别</strong>：智能卡是一种不可复制的硬件， 内置集成电路的芯⽚，具有硬件加密功能。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;存取控制&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;存取控制机制组成&lt;/font&gt;</h4>
<p><strong>存取控制机制主要包括定义用户权限和合法权限检查两部分。</strong></p>
<ol>
<li>定义用户权限，并将用户权限登记到数据字典中</li>
</ol>
<p>DBMS 提供适当的语言来定义用户权限，存放在数据字典中，称做安全规则或授权规则。</p>
<ol>
<li>合法权限检查</li>
</ol>
<p>用户发出存取数据库操作请求。DBMS 查找数据字典，进行合法权限检查。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt; 常用存取控制方法  &lt;/font&gt;</h4>
<ol>
<li><strong>自主存取控制</strong> &lt;font style="color:#ED740C;"&gt;DAC, Discretionary Access Control&lt;/font&gt;</li>
</ol>
<p>用户对于不同的数据库对象有不同的存取权限，不同的用户对同一对象也有不同的权限， 用户可将其拥有的存取权限转授给其他用户。</p>
<p>自主存取控制非常灵活。</p>
<ol>
<li><strong>强制存取控制</strong> &lt;font style="color:#ED740C;"&gt;MAC, Mandatory Access Control&lt;/font&gt;</li>
</ol>
<p>每一个数据库对象被标以一定的密级，每一个用户也被授予某一个级别的许可证。</p>
<p>对于任意一个对象，只有具有合法许可证的用户才可以存取。</p>
<p>强制存取控制因此相对比较严格。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;自主存取控制方法&lt;/font&gt;</h4>
<p><strong>用户权限是由两个要素组成的：数据库对象和操作类型</strong>。定义存取权限称为 <strong>授权</strong> <code>authorization</code>。</p>
<p>RDBMS 存取控制的对象不仅有数据本身还有数据库模式，关系数据库系统中存取控制对象如下：</p>
<p><img src="./Database-Systems25.png" alt="" /></p>
<p>在给用户授予列 INSERT 权限时，需要包含主码的 INSERT 权限，否则用户的插入动作会因为主码为空而被拒绝。</p>
<p><strong>&lt;font style="color:#2F4BDA;"&gt;授权：授予与收回&lt;/font&gt;</strong></p>
<p>SQL 中使用 GRANT 和 REVOKE 语句向用户授予或收回对数据的操作权限。</p>
<p>GRANT 语句向用户授予权限，REVOKE 语句收回己经授予用户的权限。</p>
<p>&lt;font style="color:#ED740C;"&gt;GRANT&lt;/font&gt;：<strong>权限授予</strong></p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【GRANT &lt; 权限 &gt; [ , &lt; 权限 &gt; ] ...</p>
<p>​				ON &lt; 对象类型 &gt; &lt; 对象名 &gt; [ , &lt; 对象类型 &gt; &lt; 对象名 &gt;] ...</p>
<p>​				TO &lt; 用户 &gt; [ , &lt; 用户 &gt; ] ...</p>
<p>​				[ &lt;font style="color:#ED740C;"&gt;WITH GRANT OPTION&lt;/font&gt; ] ;】</p>
<p>说明：接受权限的用户可以是一个或多个具体用户，也可以是 &lt;font style="color:#ED740C;"&gt;PUBLIC&lt;/font&gt;，即全体用户。</p>
<p>&lt;font style="color:#ED740C;"&gt;WITH GRANT OPTION&lt;/font&gt;：获得某种权限的用户还可以把这种权限再授予其他的用户。</p>
<p>SQL 标准不允许循环授权，即被授权者不能把权限再授回给授权者或其祖先。</p>
<pre><code>/*把查询 Student 表的权限授给所有用户*/
GRANT SELECT ON TABLE Student TO PUBLIC;

/*把对 Student 表和 Course 表的全部操作权限授予用户 U2 和 U3 */
GRANT ALL PRIVILEGES ON TABLE Student, Course TO U2,U3;

/*把查询 Student 表和修改学生学号的权限受给用户 U4，并允许将此权限再授予其他用户。*/
GRANT UPDATE(Sno),SELECT
      ON TABLE Student TO U4
      WITH GRANT OPTION;
</code></pre>
<p>&lt;font style="color:#ED740C;"&gt;REVOKE&lt;/font&gt;：<strong>权限回收</strong></p>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;ROVOKE&lt;/font&gt; &lt; 权限 &gt; [ , &lt; 权限 &gt; ] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;ON&lt;/font&gt; &lt; 对象类型 &gt; &lt; 对象名 &gt; [ , &lt; 对象类型 &gt; &lt; 对象名 &gt;] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;FROM&lt;/font&gt; &lt; 用户 &gt; [ ,  &lt; 用户 &gt; ] ...</p>
<p>​				[ &lt;font style="color:#ED740C;"&gt;CASCADE&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;RESTRICT&lt;/font&gt; ] ;】</p>
<p>说明：数据库管理员或其他授权者可以使用 REVOKE 语句将授予的权限收回。</p>
<pre><code>/*把用户 U4 修改学生学号的权限收回*/
REVOKE UPDATE(Sno) ON TABLE Student FROM U4 CASCADE;
</code></pre>
<p>创建数据库模式的权限 对创建数据库模式一类的数据库对象的授权由数据库管理员在创建用户时实现。</p>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;CREATE USER&lt;/font&gt; &lt; 用户名 &gt; [ &lt;font style="color:#601BDE;"&gt;WITH&lt;/font&gt; ] [ &lt;font style="color:#ED740C;"&gt;DBA&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;RESOURCE&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;CONNECT&lt;/font&gt; ] ;】</p>
<p>&lt;font style="color:#81DFE4;"&gt;说明&lt;/font&gt;：新创建的数据库用户有三&lt;font style="color:#ED740C;"&gt;&lt;/font&gt;种权限：CONNECT、RESOURCE 和 DBA，默认 CONNECT。权限如下：</p>
<p><img src="./Database-Systems26.png" alt="" /></p>
<h4>&lt;font style="color:#2F4BDA;"&gt;数据库角色&lt;/font&gt;</h4>
<p>数据库角色是被命名的一组与数据库操作相关的权限，角色是权限的集合。</p>
<p>可以为一组具有相同权限的用户创建一个角色，简化授权。</p>
<p>&lt;font style="color:#ED740C;"&gt;角色的创建&lt;/font&gt;</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;CREATE ROLE&lt;/font&gt; &lt; 角色名 &gt; ;】</p>
<pre><code>/*创建一个角色 R1*/
CREATE ROLE R1:
</code></pre>
<p>&lt;font style="color:#ED740C;"&gt;给角色授权 &lt;/font&gt;&lt;font style="color:#601BDE;"&gt;&lt;/font&gt;</p>
<p>语句格式：【&lt;font style="color:#601BDE;"&gt;GRANT&lt;/font&gt; &lt; 权限 &gt; [ , &lt; 权限 &gt; ] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;ON &lt;/font&gt;&lt; 对象类型 &gt; &lt; 对象名 &gt; [ , &lt; 对象类型 &gt; &lt; 对象名 &gt;] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;TO&lt;/font&gt; &lt; 角色 &gt; [ , &lt; 角色 &gt; ] ... ;】</p>
<pre><code>/*授予角色 RI Student 表的 SELECT、UPDATE、 INSERT 权限*/
GRANT SELECT,UPDATE,INSERT
      ON TABLE Student 
      TO R1; 
</code></pre>
<p>&lt;font style="color:#ED740C;"&gt;将一个角色授予其他的角色或用户 &lt;/font&gt;</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;GRANT&lt;/font&gt; &lt; 角色1 &gt; [ , &lt; 角色2 &gt; ] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;TO&lt;/font&gt; &lt; 角色3 &gt; [ , &lt; 用户1 &gt; ] ...</p>
<p>​				[ &lt;font style="color:#ED740C;"&gt;WITH ADMIN OPTION&lt;/font&gt; ] ;】</p>
<p>&lt;font style="color:#81DFE4;"&gt;说明&lt;/font&gt;：&lt;font style="color:#ED740C;"&gt;WITH ADMIN OPTION&lt;/font&gt;：获得某种权限的角色或用户还可以把这种权限再授予其他的角色或用户。</p>
<pre><code>/*将角色 R1 授予WANG、ZHANG、ZHAO，使他们具有角色 R1 所包含的全部权限，且可授权子角色及用户*/
GRANT R1 TO WANG、ZHANG、ZHAO WITH ADMIN OPTION；
</code></pre>
<p>&lt;font style="color:#ED740C;"&gt;角色权限的收回 &lt;/font&gt;</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;ROVOKE&lt;/font&gt; &lt; 权限 &gt; [ , &lt; 权限 &gt; ] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;ON&lt;/font&gt; &lt; 对象类型 &gt; &lt; 对象名 &gt; [ , &lt; 对象类型 &gt; &lt; 对象名 &gt;] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;FROM&lt;/font&gt; &lt; 角色 &gt; [ ,  &lt; 角色 &gt; ] ...  ; 】</p>
<pre><code>/*收回 R1 在 Student 表的 SELECT 权限*/
REVOKE SELECT ON TABLE Student FROM R1;
</code></pre>
<p>&lt;font style="color:#ED740C;"&gt;角色回收&lt;/font&gt; 【( * 待补充 ) 原书及课程并无说明，仅有一例回收角色，暂不清楚是否区分 CASCADE 和 RESTRICT。】</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;REVOKE&lt;/font&gt; &lt; 角色1 &gt; [ , &lt; 角色2 &gt; ] ...</p>
<p>​				&lt;font style="color:#601BDE;"&gt;FROM&lt;/font&gt; &lt; 角色3 &gt; [ ,  &lt; 用户1 &gt; ] ...</p>
<p>​				[ &lt;font style="color:#ED740C;"&gt;CASCADE&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;RESTRICT &lt;/font&gt;] ;】</p>
<pre><code>REVOKE R1 FROM WANG;
</code></pre>
<h4>强制存取控制方法</h4>
<p>自主存取控制可能存在数据 “无意泄露” 的风险</p>
<p><strong>原因</strong>：DAC 仅仅通过对数据的存取权限来进行安全控制而数据本身并无安全性标记。</p>
<p><strong>解决</strong>：对系统控制下的所有主客体实施强制存取控制策略 MAC。</p>
<p>&lt;font style="color:#ED740C;"&gt;强制存取控制 &lt;/font&gt;&lt;font style="color:#ED740C;"&gt;MAC&lt;/font&gt;</p>
<p><strong>作用</strong>：保证更高程度的安全性、用户不能直接感知或进行控制。</p>
<p>适用于对数据有严格而固定密级分类的部门，如军事部门，政府部门。</p>
<p>&lt;font style="color:#81BBF8;"&gt;在强制存取控制中实体分类&lt;/font&gt;</p>
<p><strong>主体</strong>：系统中的活动实体，既包括数据库管理系统所管理的实际用户，也包括代表用户的各进程。</p>
<p><strong>客体</strong>：系统中的被动实体，是受主体操纵的，包括文件、基本表、索引、视图等。</p>
<p>&lt;font style="color:#81BBF8;"&gt;敏感度标记&lt;/font&gt;&lt;font style="color:#ED740C;"&gt;label&lt;/font&gt;</p>
<p>对于主体和客体，DBMS 为它们每个实例（值）指派一个敏感度标记。敏感度标记被分成若干级别。</p>
<p>如 <strong>绝密</strong> &lt;font style="color:#ED740C;"&gt;TS, Top Secret&lt;/font&gt;、<strong>机密</strong> &lt;font style="color:#ED740C;"&gt;S, Secret&lt;/font&gt;、<strong>可信</strong> &lt;font style="color:#ED740C;"&gt;C, Confidential&lt;/font&gt;、<strong>公开</strong> &lt;font style="color:#ED740C;"&gt;P, Public&lt;/font&gt; 等。</p>
<p>密级的次序是 TS &gt;= S &gt;= C &gt;= P。</p>
<p>主体的敏感度标记称为<strong>许可证级别</strong>&lt;font style="color:#ED740C;"&gt;clearance level&lt;/font&gt;，客体的敏感度标记称为<strong>密级</strong> &lt;font style="color:#ED740C;"&gt;classification&lt;/font&gt;&lt;font style="color:#ED740C;"&gt; &lt;/font&gt;&lt;font style="color:#ED740C;"&gt;level&lt;/font&gt;。</p>
<p>强制存取控制机制就是通过对比主体的敏感度标记和客体的敏感度标记，最终确定主体是否能够存取客体。</p>
<p>&lt;font style="color:#81BBF8;"&gt;强制存取控制规则 &lt;/font&gt;</p>
<p>仅当主体的许可证级别大于或等于客体的密级时，该主体才能读取相应的客体。</p>
<p>仅当主体的许可证级别⼩于或等于客体的密级时，该主体才能写相应的客体。</p>
<p>注：如果高级主体写了低级客体，则低级主体可访问高级主体写的数据，造成数据泄露。</p>
<p>如果低级主体写高级客体，看似荒谬但低级主体无法访问自己写的客体数据，不会造成客体密级降低。</p>
<p>MAC 是对数据本身进行密级标记，标记与数据不可分，只有符合密级标记要求的用户才可以操纵数据。</p>
<p>实现 MAC 要⾸先实现 DAC，因为较高安全性级别提供的安全保护要包含较低级别的所有保护。</p>
<p>MAC 与 DAC 共同构成数据库管理系统的安全机制。</p>
<p><img src="./Database-Systems27.png" alt="" /></p>
<h2>4.3 视图机制</h2>
<p>视图对数据库安全的作用：</p>
<ol>
<li>把要保密的数据对无权存取这些数据的用户隐藏起来，对数据提供一定程度的安全保护。</li>
<li>间接地实现支持存取谓词的用户权限定义。</li>
</ol>
<pre><code>/*建立计算机系学生的视图，把对该视图的 SELECT 权限授予王平，把该视图上的所有操作权限授予张明。*/
CREATE VIEW CS_Student
       AS SELECT *
              FROM Student
              WHERE Sdept=‘CS‘；            /*先建立视图 CS_ _Student*/
              
GRANT SELECT ON CS_Student TO 王平；         /*王平⽼师只能检索计算机系学生的信息*/

GRANT ALL PRIVILEGES ON CS_Student TO 张明； /*系主任具有检索和增删改计算机系学生信息的所有权限*/
</code></pre>
<h2>4.4 审计</h2>
<h4>&lt;font style="color:#2F4BDA;"&gt;审计&lt;/font&gt;</h4>
<p><strong>审计日志</strong> <code>Audit Log</code>：启用一个专用的审计日志户对数据库的所有操作记录在上面。</p>
<p><strong>审计员</strong>：利用审计日志监控数据库中的各种行为，找出非法存取数据的⼈、时间和内容。</p>
<p>C2 以上安全级别的 DBMS 必须具有审计功能。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;审计功能的可选性&lt;/font&gt;</h4>
<p>审计很费时间和空间。</p>
<p>DBA 可以根据应用对安全性的要求，灵活地打开或关闭审计功能。主要用于安全性要求较高的部门。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;审计事件&lt;/font&gt;</h4>
<ol>
<li>&lt;font style="color:#ED740C;"&gt;服务器事件&lt;/font&gt;：审计数据库服务器发生的事件，包含数据库服务器的启动、停止、数据库服务器配置文件的重新加载。</li>
<li>&lt;font style="color:#ED740C;"&gt;系统权限&lt;/font&gt;：对系统拥有的结构或模式对象进行操作的审计，要求该操作的权限是通过系统权限获得的。</li>
<li>&lt;font style="color:#ED740C;"&gt;语句事件&lt;/font&gt;：对 SQL 语句，如DDL、DML、DQL &lt;font style="color:#ED740C;"&gt;Data Query Language&lt;/font&gt; 数据查询语言 及 DCL 语句的审计。</li>
<li>&lt;font style="color:#ED740C;"&gt;模式对象事件&lt;/font&gt;：对特定模式对象上进行的SELECT 或 DML 操作的审计。模式对象</li>
</ol>
<h4>&lt;font style="color:#2F4BDA;"&gt;审计功能&lt;/font&gt;</h4>
<ol>
<li>&lt;font style="color:#ED740C;"&gt;基本功能&lt;/font&gt;：提供多种审计查阅方式：基本的、可选的、有限的 等。</li>
<li>&lt;font style="color:#ED740C;"&gt;提供多套审计规则&lt;/font&gt;：审计规则一般在数据库初始化时设定，以方便审计员管理。</li>
<li>&lt;font style="color:#ED740C;"&gt;提供审计分析和报表功能&lt;/font&gt;</li>
<li>&lt;font style="color:#ED740C;"&gt;审计日志管理功能&lt;/font&gt;
<ol>
<li>为防止审计员误删审计记录，审计日志必须先转储后删除；</li>
<li>对转储的审计记录文件提供完整性和保密性保护；</li>
<li>只允许审计员查阅和转储审计记录，不允许任何用户新增和修改审计记录等。</li>
</ol>
</li>
<li>&lt;font style="color:#ED740C;"&gt;系统提供查询审计设置及审计记录信息的专门视图&lt;/font&gt;</li>
</ol>
<h4>&lt;font style="color:#2F4BDA;"&gt;审计分类  &lt;/font&gt;</h4>
<p>&lt;font style="color:#ED740C;"&gt;用户级审计 &lt;/font&gt;</p>
<p>任何用户可设置的审计， 主要是用户针对自己创建的数据库表或视图进行审计。</p>
<p>&lt;font style="color:#ED740C;"&gt;系统级审计 &lt;/font&gt;</p>
<p>只能由数据库管理员设置，用以监测成功或失败的登录要求、监测授权和收回操作及其他数据库级权限下的操作。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;AUDIT 语句和 NOAUDIT 语句&lt;/font&gt;</h4>
<p>AUDIT 语句用来设置审计功能</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;AUDIT&lt;/font&gt; &lt; 权限1 &gt; [ , &lt; 权限2 &gt;]...</p>
<p>&lt;font style="color:#601BDE;"&gt;ON&lt;/font&gt; &lt; 对象类型 &gt; &lt; 对象名 &gt; [ , &lt; 对象类型 &gt; &lt; 对象名 &gt;] ...</p>
<p>[ &lt;font style="color:#601BDE;"&gt;BY&lt;/font&gt; [ &lt;font style="color:#ED740C;"&gt;SESSION&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;ACCESS&lt;/font&gt; ] ]</p>
<p>[&lt;font style="color:#601BDE;"&gt; WHENEVER&lt;/font&gt; [ &lt;font style="color:#ED740C;"&gt;SUCCESSFUL&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;NOT SUCCESSFUL&lt;/font&gt; | &lt;font style="color:#ED740C;"&gt;ANY&lt;/font&gt; ] ] ;】</p>
<p>BY 子句：指定审计粒度， &lt;font style="color:#ED740C;"&gt;SESSION&lt;/font&gt; 表示每个会话记录一次，&lt;font style="color:#ED740C;"&gt;ACCESS&lt;/font&gt; 表示每次访问都记录。默认 &lt;font style="color:#ED740C;"&gt;SESSION&lt;/font&gt;。</p>
<p>WHENEVER 子句：精准地监控和分析数据库活动，&lt;font style="color:#ED740C;"&gt;SUCCESSFUL&lt;/font&gt; 仅审计成功执行的操作，&lt;font style="color:#ED740C;"&gt;NOT SUCCESSFUL&lt;/font&gt; 仅审计 执行失败的操作， &lt;font style="color:#ED740C;"&gt;ANY&lt;/font&gt; 同时审计成功和失败的操作。默认 &lt;font style="color:#ED740C;"&gt;ANY&lt;/font&gt;。</p>
<pre><code>/*对修改 SC 表结构或修改 SC 表数据的操作进行审计*/
AUDIT ALTER, UPDATE ON SC:
</code></pre>
<p>NOAUDIT 语句则取消审计功能。</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;NOAUDIT &lt;/font&gt;&lt; 权限1 &gt; [ , &lt; 权限2 &gt;]...</p>
<p>&lt;font style="color:#ED740C;"&gt;ON &lt;/font&gt;&lt; 对象类型 &gt; &lt; 对象名 &gt; [ , &lt; 对象类型 &gt; &lt; 对象名 &gt;] ... ;】</p>
<pre><code>/*取消对 SC 表的一切审计*/
NOAUDIT ALTER, UPDATE ON SC;
</code></pre>
<h2>4.5 数据加密</h2>
<p>数据加密是防止数据库中数据在存储和传输中失密的有效手段。</p>
<p>基本思想：根据一定的算法将原始数据 / <strong>明文</strong>&lt;font style="color:#ED740C;"&gt;plain text&lt;/font&gt;变换为不可直接识别的格式 / <strong>密文</strong>&lt;font style="color:#ED740C;"&gt;Cipher text&lt;/font&gt;。</p>
<p>数据加密包括存储加密和传输加密。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;存储加密&lt;/font&gt;</h4>
<p>一般提供透明和非透明两种存储加密方式。</p>
<p>透明存储加密是内核级加密保护方式，对用户完全透明；非透明存储加密则是通过多个加密函数实现的。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;传输加密&lt;/font&gt;</h4>
<p>常用的传输加密方式如链路加密和端到端加密。</p>
<p><strong>&lt;font style="color:#ED740C;"&gt;链路加密 &lt;/font&gt;</strong></p>
<p>对传输数据在链路层进行加密，它的传输信息由报头和报文两部分组成，前者是路由选择信息，而后者是传送的数 据信息。这种方式对报文和报头均加密。</p>
<p><strong>&lt;font style="color:#ED740C;"&gt;端到端加密&lt;/font&gt;</strong>&lt;font style="color:#ED740C;"&gt; &lt;/font&gt;</p>
<p>对传输数据在发送端加密，接收端解密。它只加密报文，不加密报头。</p>
<p>所需密码设备数量相对较少，容易被非法监听者发现并从中获取敏感信息。</p>
<p>基于基于安全套接层协议&lt;font style="color:#ED740C;"&gt;SSL, Security Socket Layer&lt;/font&gt; 的数据库管理系统可信传输方案：</p>
<p><img src="./Database-Systems28.png" alt="" /></p>
<h2>4.6 其他安全性保护</h2>
<p><strong>&lt;font style="color:#2F4BDA;"&gt;推理控制&lt;/font&gt;</strong> <code>inference control</code></p>
<p>处理强制存取控制未解决的问题，避免用户利用能够访问的数据推知更高密级的数据。</p>
<p>常用方法：基于函数依赖的推理控制、基于敏感关联的推理控制。</p>
<p><strong>&lt;font style="color:#2F4BDA;"&gt;隐蔽信道&lt;/font&gt;</strong> <code>covert channal</code></p>
<p>处理强制存取控制未解决的问题。</p>
<p><strong>&lt;font style="color:#2F4BDA;"&gt;数据隐私保护&lt;/font&gt;</strong></p>
<p>描述个⼈控制其不愿他⼈知道或他⼈不便知道的个⼈数据的能⼒。</p>
<p>主要用于数据收集存储处理和数据发布等各个阶段。</p>
<h1>&lt;font style="color:#2F4BDA;"&gt;Note 05 数据库完整性&lt;/font&gt;</h1>
<h2>5.0 数据库完整性简介</h2>
<h4>&lt;font style="color:#2F4BDA;"&gt;数据库的完整性&lt;/font&gt;</h4>
<p>数据库的 <strong>完整性</strong> &lt;font style="color:#ED740C;"&gt;integrity&lt;/font&gt; 是指数据的 <strong>正确性</strong> &lt;font style="color:#ED740C;"&gt;correctness&lt;/font&gt; 和<strong>相容性</strong> &lt;font style="color:#ED740C;"&gt;compat- ability&lt;/font&gt;。</p>
<p><strong>正确性</strong>：指数据是符合现实世界语义、反映当前实际状况的；</p>
<p><strong>相容性</strong>：指数据库同一对象在不同关系表中的数据是符合逻辑的。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;完整性与安全性的区别&lt;/font&gt;</h4>
<p><strong>数据的完整性</strong>：防止数据库中存在不符合语义的数据，也就是防止数据库中存在不正确的数据。</p>
<p><strong>防范对象</strong>：不合语义的、不正确的数据。</p>
<p><strong>数据的安全性</strong>：保护数据库防止恶意的破坏和非法的存取。</p>
<p><strong>防范对象</strong>：非法用户和非法操作。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;数据库在完整性方面应具备的功能&lt;/font&gt;</h4>
<p>&lt;font style="color:#ED740C;"&gt;提供定义完整性约束条件的机制 &lt;/font&gt;</p>
<p>完整性约束条件也称为完整性规则，是数据库中的数据必须满足的语义约束条件。</p>
<p>SQL 标准使用了一系列概念来描述完整性，包括关系模型的实体完整性、参照完整性和用户定义完整性。</p>
<p>&lt;font style="color:#ED740C;"&gt;提供完整性检查的方法 &lt;/font&gt;</p>
<p>数据库管理系统中检查数据是否满足完整性约束条件的机制称为完整性检查。</p>
<p>一般在 INSERT、UPDATE、DELETE 语句执行后开始检查，也可以在事务提交时检查。</p>
<p>&lt;font style="color:#ED740C;"&gt;进行违约处理 &lt;/font&gt;</p>
<p>数据库管理系统若发现用户的操作违背了完整性约束条件， 就采取 <strong>拒绝</strong> &lt;font style="color:#ED740C;"&gt;NO ACTION&lt;/font&gt; 执行该操作或 <strong>级连</strong> &lt;font style="color:#ED740C;"&gt;CASCADE&lt;/font&gt; 执行其他操作或 <strong>设空值</strong> 等方式保证完整性。</p>
<h2>5.1 实体完整性</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;实体完整性定义&lt;/font&gt;</h3>
<p>关系模型的实体完整性在 CREATE TABLE 中用 PRIMARY KEY 定义。</p>
<p>对单属性构成的码有两种说明方法，一种是定义为列级约束条件，另一种是定义为表级约束条件。</p>
<p>对多个属性构成的码只有一种说明方法，即定义为表级约束条件。</p>
<pre><code>/*单属性构成的码：将 Student 表中的 Sno 属性定义为码*/
CREATE TABLE Student
      (Sno CHAR(9) PRIMARY KEY,            /*在列级定义主码*/
       Sname CHAR(20) NOT NULL,
       Ssex CHAR(2),
       Sage SMALLINT,
       Sdept CHAR(20)
      );
CREATE TABLE Student
       (Sno CHAR(9),
       Sname CHAR(20) NOT NULL,
       Ssex CHAR(2),
       Sage SMALLINT,
       Sdept CHAR(20),
       PRIMARY KEY(Sno)                    /*在表级定义主码*/
      );
/*多属性构成的码：将 SC 表中的 Sno、Cno 属性组定义为码*/
CREATE TABLE SC
      (Sno CHAR(9) NOT NULL,
       Cno CHAR(4) NOT NULL,
       Grade SMALLINT,
       PRIMARY KEY (Sno,Cno)               /*只能在表级定义主码*/
      );
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;实体完整性检查和违约处理&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;完整性检查的内容&lt;/font&gt;</h4>
<p>关系数据库管理系统按照实体完整性规则自动进行检查。检查内容主要包括：</p>
<ol>
<li>检查主码值是否唯一，如果不唯一则拒绝插入或修改。</li>
<li>检查主码的各个属性是否为空，只要有一个为空就拒绝插入或修改。</li>
</ol>
<h4>&lt;font style="color:#2F4BDA;"&gt;检查主码值方法&lt;/font&gt;</h4>
<p>检查记录中主码值是否唯一的一种方法是进行 <strong>全表扫描</strong>：</p>
<p>依次判断表中每一条记录的主码值与将插入记录的主码值（或者修改的新主码值）是否相同。</p>
<p>全表扫描十分耗时。</p>
<p><img src="./Database-Systems29.png" alt="" /></p>
<p>为避免对基本表进行全表扫描，RDBMS 核心一般都在主码上自动建立一个索引。如 B+ 树索引。</p>
<p><img src="./Database-Systems30.png" alt="" /></p>
<h2>5.2 参照完整性</h2>
<h3>&lt;font style="color:#DF2A3F;"&gt;参照完整性定义&lt;/font&gt;</h3>
<p>关系模型的参照完整性在 CREATE TABLE 中用 FOREIGN KEY 短语定义哪些列为外码， 用 REFERENCES 短语指明这些外码参照哪些表的主码。</p>
<pre><code>/*定义 SC 中的参照完整性*/
CREATE TABLE SC
      (Sno CHAR(9) NOT NULL，
       Cno CHAR(4) NOT NULL，
       Grade SMALLINT，
       PRIMARY KEY (Sno， Cno)，                       /*在表级定义实体完整性*/
       FOREIGN KEY (Sno) REFERENCES Student(Sno)，     /*在表级定义参照完整性*/
       FOREIGN KEY (Cno) REFERENCES Course(Cno)        /*在表级定义参照完整性*/
      )；
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;参照完整性检查和违约处理&lt;/font&gt;</h3>
<p>参照完整性将两个表中的相应元组联系起来了。</p>
<p>对被参照表和参照表进行增、删、改操作时有可能破坏参照完整性，此时必须进行检查以保证这两个表的相容性。</p>
<p><img src="./Database-Systems31.png" alt="" /></p>
<p>当发生以上 4 种可能破坏参照完整性的情况时，系统可以采用以下 3 种策略加以处理。</p>
<h4>拒绝 <code>NO ACTION</code></h4>
<p>不允许该操作执行。该策略一般设置默认策略。</p>
<h4>级连 <code>CASCADE</code></h4>
<p>当删除或修改被参照表的一个元组导致与参照表的不一致时，删除或修改参照表中的所有导致不一致的元组。</p>
<h4>设置为空值</h4>
<p>当删除或修改被参照表的一个元组时造成了不一致，则将参照表中的所有造成不一致的元组对应属性设置为空值。</p>
<pre><code>/*显式说明参照完整性的违约处理示例*/
CREATE TABLE SC
    (Sno CHAR(9)，
     Cno CHAR(4)，
     Grade SMALLINT，
     PRIMARY KEY (Sno，Cno)，             /*在表级定义实体完整性，Sno、Cno 都不能取空值*/ 
     FOREIGN KEY (Sno) REFERENCES Student(Sno)   /*在表级定义参照完整性*/
          ON DELETE CASCADE           /*当删除 Student 表中的元组时，级联删除SC表中相应的元组*/
          ON UPDATE CASCADE,          /*当更新 Student 表中的 sno 时，级联更新SC表中相应的元组*/
     FOREIGN KEY (Cno) REFERENCES Course(Cno)    /*在表级定义参照完整性*/ 
          ON DELETE NO ACTION        /*当删除 Course 表中的元组造成与SC表不一致时，拒绝删除*/
          ON UPDATE CASCADE          /*当更新 Course 表中的cno 时，级联更新SC 表中相应的元组*/
)；
</code></pre>
<h2>5.3 用户定义的完整性</h2>
<p>用户定义的完整性就是针对某一具体应用的数据必须满足的语义要求。</p>
<p>目前的关系数据库管理系统都提供了定义和检验这类完整性的机制，不必由应用程序承担这一功能。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;属性上的约束条件&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;属性上约束条件的定义&lt;/font&gt;</h4>
<p>在 CREATE TABLE 中定义属性的同时，可以根据应用要求定义属性上的约束条件， 即属性值限制，包括：</p>
<ol>
<li><strong>列值非空</strong>： <code>NOT NULL</code></li>
<li><strong>列值唯一</strong>： <code>UNIQUE</code></li>
<li>检查列值是否满足一个<strong>条件表达式</strong>： <code>CHECK</code> 短语</li>
</ol>
<pre><code>/*建⽴部⻔表 DEPT，要求部⻔名称 Dname 列取值唯一，城市只允许在北京或上海，部⻔编号 Deptno 列为主码*/
CREATE TABLE DEPT
      (Deptno NUMERIC(2),
       Dname CHAR(9) UNIQUE NOT NULL,        /*要求 Dname 列值唯一,且不能取空值*/
       Location CHAR(10) CHECK(Location IN ('Beijing','Shanghai')), /*城市只允许在北京或上海*/
       PRIMARY KEY (Deptno) 
      ):
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;属性上约束条件的检查和违约处理&lt;/font&gt;</h4>
<p>当往表中插入元组或修改属性的值时，RDBMS 将检查属性上的约束条件是否被满足，不满足则操作被拒绝执行。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;元组上的约束条件&lt;/font&gt;</h3>
<h4>&lt;font style="color:#2F4BDA;"&gt;元组上约束条件的定义&lt;/font&gt;</h4>
<p>在 CREATE TABLE 时可以用 CHECK 短语定义元组上的约束条件，即元组级的限制。</p>
<p>元组级的限制可以设置不同属性之间的取值的相互约束条件。</p>
<pre><code>/*当学生的性别是男时，其名字不能以 Ms.打头*/
CREATE TABLE Student
    (Sno CHAR(9),
     Sname CHAR(8) NOT NULL,
     Ssex CHAR(2),
     Sage SMALLINT,
     Sdept CHAR(20),
     PRIMARY KEY (Sno),
    CHECK (Ssex='½' OR Sname NOT LIKE 'Ms.%') /*定义了元组中 Sname和 Ssex两个属性值之间的约東条件
*/
    );
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;元组上约束条件的检查和违约处理&lt;/font&gt;</h4>
<p>当往表中插入元组或修改属性的值时，RDBMS 将检查属性上的约束条件是否被满足，不满足则操作被拒绝执行。</p>
<h2>5.4 完整性约束命名子句</h2>
<h4>&lt;font style="color:#2F4BDA;"&gt;完整性约束命名子句&lt;/font&gt;</h4>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#601BDE;"&gt;CONSTRAINT&lt;/font&gt; &lt; 完整性约束条件名 &gt; &lt; 完整性约束条件 &gt;】</p>
<p>&lt;font style="color:#81DFE4;"&gt;说明&lt;/font&gt;：&lt; 完整性约束条件 &gt; 包括 NOT NULL、UNIQUE、PRIMARY KEY、FOREIGN KEY、 CHECK 短语 等。</p>
<pre><code>/*建立学生登记表 Student，要求学号在90000～99999之间，姓名不能取空值，年龄⼩于30，性别只能是“男”或“⼥”*/
CREATE TABLE Student
      (Sno NUMERIC(6)
          CONSTRAINT CI CHECK (Sno BETWEEN 90000 AND 99999), /*C1 列级约束*/
       Sname CHAR(20)
          CONSTRAINT C2 NOT NULL,                            /*C2 列级约束*/
       Sage NUMERIC(3)
          CONSTRAINT C3 CHECK (Sage &lt; 30),                   /*C3 列级约束*/
       Ssex CHAR(2)
          CONSTRAINT C4 CHECK (Ssex IN ('男'，'⼥')),         /*C4 列级约束*/
       CONSTRAINT StudentKey PRIMARY KEY(Sno)      /*名为StudentKey的主码约束：Sno设为主码*/
       );
</code></pre>
<h4>&lt;font style="color:#2F4BDA;"&gt;修改表中的完整性限制&lt;/font&gt;</h4>
<p>可以使用 ALTER TABLE 语句修改表中的完整性限制。</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：</p>
<p>【&lt;font style="color:#601BDE;"&gt;ALTER TABLE&lt;/font&gt; &lt; 表名 &gt; &lt;font style="color:#601BDE;"&gt;ADD CONSTRAINT&lt;/font&gt; &lt; 完整性约束名 &gt; &lt; 完整性约束条件 &gt; ;】</p>
<p>【&lt;font style="color:#601BDE;"&gt;ALTER TABLE&lt;/font&gt; &lt; 表名 &gt; &lt;font style="color:#601BDE;"&gt;DROP CONSTRAINT &lt;/font&gt;&lt; 完整性约束名 &gt; [ CASCADE | RESTRICT ] ] ;】</p>
<p>&lt;font style="color:#81DFE4;"&gt;说明&lt;/font&gt;：约束条件的修改通过可以先删除原来的约束条件，再增加新的约束条件完成。</p>
<pre><code>/*修改表 Student 中的约束条件，要求学号改为在900000~999999之间， 年龄由⼩于30改为⼩于40*/
ALTER TABLE Student 
      DROP CONSTRAINT C1;
ALTER TABLE Student 
      ADD CONSTRAINT C1 CHECK (Sno BETWEEN 900000 AND 999999):
ALTER TABLE Student
      DROP CONSTRAINT C3;
ALTER TABLE Student
      ADD CONSTRAINT C3 CHECK (Sage &lt; 40);
</code></pre>
<h2>5.5 域中的完整性限制</h2>
<p>域是一组具有相同数据类型的值的集合，即属性的取值范围。</p>
<p>SQL 可以用 CREATE DOMAIN 语句建立一个域以及域应该满足的完整性约束条件，然后用域定义属性。</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：</p>
<p>【&lt;font style="color:#601BDE;"&gt;CREATE DOMAIN&lt;/font&gt; &lt; 域名 &gt; &lt; 数据类型 &gt; &lt; 完整性约束条件 &gt; ;】</p>
<p>【&lt;font style="color:#601BDE;"&gt;ALTER DOMAIN&lt;/font&gt; &lt; 域名 &gt; &lt;font style="color:#601BDE;"&gt;ADD CONSTRAINT&lt;/font&gt; &lt; 完整性约束名 &gt; &lt; 完整性约束条件 &gt; ;】</p>
<p>【&lt;font style="color:#601BDE;"&gt;ALTER DOMAIN&lt;/font&gt; &lt; 域名 &gt; &lt;font style="color:#601BDE;"&gt;DROP CONSTRAINT&lt;/font&gt; &lt; 完整性约束名 &gt; [ CASCADE | RESTRICT ] ] ;】</p>
<pre><code>/*把性别的取值范围由（男，⼥）改为（1，0）*/

/*建立一个性别域，并声明性别域的取值范围*/
CREATE DOMAIN GenderDomain CHAR(2) 
       CHECK (VALUE IN ('男','⼥'));
       /*对 Ssex 的说明可以改写为 ·Ssex GenderDomain· */
       
/*建立一个性别域 GenderDomain，并对其中的限制命名*/
CREATE DOMAIN GenderDomain CHAR(2) 
       CONSTRAINT GD CHECK (VALUE IN ('男','⼥'));
/*删除域 GenderDomain 的限制条件GD*/
ALTER DOMAIN GenderDomain 
      DROP CONSTRAINT GD;
/*在域 GenderDomain 上增加性别的限制条件 GDD*/
ALTER DOMAIN GenderDomain
      ADD CONSTRAINT GDD 
          CHECK (VALUE IN ('1','0'));
</code></pre>
<h2>5.6 断言</h2>
<p>在 SQL 中通过 <strong>声明性断言</strong> <code>declarative assertions</code> 来指定更具一般性的约束。</p>
<p>可以定义涉及多个表或聚集操作的比较复杂的完整性约束。断言创建以后， 任何对断言中所涉及关系的操作都会触发关系数据库管理系统对断言的检查， 任何使断言不为真值的操作都会被拒绝执行。</p>
<h4>&lt;font style="color:#2F4BDA;"&gt;创建断言的语句格式&lt;/font&gt;</h4>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【CREATE ASSERTION &lt; 断言名 &gt; &lt; CHECK 子句 &gt; ;】</p>
<pre><code>/*限制数据库课程最多60名学⽣选修*/
CREATE ASSERTION ASSE_SC_DB_NUM
       CHECK(60 &gt;= (SELECT count(*)
                            FROM Course,SC
                            WHERE SC.CNO=COURSE.CNO AND
                                  COURSE.CNAME = '数据库')    
            );
</code></pre>
<p>&lt;font style="color:#2F4BDA;"&gt;删除断言的语句格式&lt;/font&gt;</p>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;： 【 CREATE ASSERTION &lt; 断言名 &gt; &lt; CHECK 子句 &gt; ; 】</p>
<h2>5.7 触发器</h2>
<p><strong>触发器</strong> &lt;font style="color:#ECAA04;"&gt;trigger&lt;/font&gt;：用户定义在关系表上的一类由事件驱动的特殊过程。</p>
<ol>
<li>触发器保存在数据库服务器中。</li>
<li>任何用户对表的增、删、改操作均由服务器自动激活相应的触发器。</li>
<li>触发器可以实施更为复杂的检查和操作，具有更精细和更强大的数据控制能⼒。</li>
</ol>
<h3>&lt;font style="color:#DF2A3F;"&gt;定义触发器&lt;/font&gt;</h3>
<p>触发器⼜叫做 <strong>事件</strong>—<strong>条件</strong>—<strong>动作</strong> &lt;font style="color:#ECAA04;"&gt;event-condition-action&lt;/font&gt; 规则。</p>
<p>语句格式：</p>
<p>【&lt;font style="color:#601BDE;"&gt;CREATE TRIGGER&lt;/font&gt; &lt; 触发器名 &gt;</p>
<p>​			{ BEFORE | AFTER } &lt; 触发事件 &gt; ON &lt; 表名 &gt;</p>
<p>​			REFERENCING NEW | OLD ROW AS &lt; 变量 &gt;</p>
<p>​			FOR EACH { ROW | STATEMENT }</p>
<p>​			[ WHEN &lt; 触发条件 &gt; ] &lt; 触发动作体 &gt; ;】</p>
<p>&lt;font style="color:#81DFE4;"&gt;说明&lt;/font&gt;： 表的拥有者才可以在表上创建触发器。</p>
<table>
<thead>
<tr>
<th><strong>语法部分</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>&lt;font style="color:#ED740C;"&gt;触发器名&lt;/font&gt;</td>
<td>可以包含模式名，也可以不包含模式名。</td>
</tr>
<tr>
<td></td>
<td>同一模式下，触发器名必须是唯一的，并且触发器和表名必须在同一模式下。</td>
</tr>
<tr>
<td>&lt;font style="color:#ED740C;"&gt;表名&lt;/font&gt;</td>
<td>触发器只能定义在基本表上，不能定义在视图上。该表也称为触发器的目标表。</td>
</tr>
<tr>
<td>&lt;font style="color:#ED740C;"&gt;触发事件&lt;/font&gt;</td>
<td>触发事件可以是 <code>INSERT</code>、<code>DELETE</code>或 <code>UPDATE</code>，也可以是这几个事件的组合。</td>
</tr>
<tr>
<td></td>
<td>【<code>UPDATE OF</code>触发列，...】：进一步指明修改哪些列时激活触发器。</td>
</tr>
<tr>
<td></td>
<td>触发时机：&lt;font style="color:#ED740C;"&gt;AFTER&lt;/font&gt;触发事件操作执行之后激活；&lt;font style="color:#ED740C;"&gt;BEFORE&lt;/font&gt;触发事件操作执行之前激活。</td>
</tr>
<tr>
<td>&lt;font style="color:#ED740C;"&gt;触发器类型&lt;/font&gt;</td>
<td>可分为行级触发器 &lt;font style="color:#ED740C;"&gt;FOR EACH ROW&lt;/font&gt;和语句级触发器 &lt;font style="color:#ED740C;"&gt;FOR EACH STATEMENT&lt;/font&gt;。</td>
</tr>
<tr>
<td></td>
<td>行级触发器：每当影响到表中的一行数据时，触发器就会被触发一次。</td>
</tr>
<tr>
<td></td>
<td>语句级触发器：执行一个数据库操作语句时，触发器才会被触发一次。</td>
</tr>
<tr>
<td>&lt;font style="color:#ED740C;"&gt;触发条件&lt;/font&gt;</td>
<td><code>WHEN</code>&lt;br/&gt; 触发条件：只有当触发条件为真时触发动作体才执行，否则触发动作体不执行。</td>
</tr>
<tr>
<td></td>
<td>如果省略，则触发动作体在触发器激活后立即执行。</td>
</tr>
<tr>
<td>&lt;font style="color:#ED740C;"&gt;触发动作体&lt;/font&gt;</td>
<td>触发动作体既可以是一个匿名 PL/SQL 过程块，也可以是对已创建存储过程的调用。</td>
</tr>
<tr>
<td></td>
<td>行级触发器：可以在过程中使用 &lt;font style="color:#ED740C;"&gt;NEW&lt;/font&gt;和 &lt;font style="color:#ED740C;"&gt;OLD&lt;/font&gt;引用事件之后的新值和事件之前的旧值。</td>
</tr>
<tr>
<td></td>
<td>语句级触发器：不能在触发动作体中使用 &lt;font style="color:#ED740C;"&gt;NEW&lt;/font&gt;和 &lt;font style="color:#ED740C;"&gt;OLD&lt;/font&gt;进行引用，无法区分新旧。</td>
</tr>
<tr>
<td></td>
<td>如果触发动作体执行失败，激活触发器的事件就会终止执行。</td>
</tr>
<tr>
<td></td>
<td>触发器的目标表或触发器可能影响的其他对象不发生任何变化。</td>
</tr>
</tbody>
</table>
<pre><code>/*当对表 SC 的 Grade 属性进⾏修改时，若分数增加了 10% ，则将此次操作记录到另一个表
    SC_U(Sno,Cno,Oldgrade,Newgrade)中*/
CREATE TRIGGER SC_T                                 /*SC_T是触发器的名字*/
       AFTER UPDATE OF Grade ON SC                  /*当对SC的Grade属性更新完后触发下面的规则*/
       REFERENCING
            OLD ROW AS OldTuple，
            NEW ROW AS NewTuple
       FOR EACH ROW                                  /*⾏级触发器，即每执⾏一次更新，执⾏一次下述操作*/ 
       WHEN (NewTuple.Grade &gt;= 1.1 * OldTuple.Grade) /*触发条件，只有该条件为真时才执⾏下面的操作*/
       INSERT INTO SC_U (Sno,Cno,OldGrade,NewGrade)  /*存储过程*/
            VALUES(OldTuple.Sno,OldTuple.Cno，OldTuple.Grade,NewTuple.Grade);
            
/*定义一个 BEFORE ⾏级触发器，为教师表 Teacher 定义完整性规则
      “教授的工资不得低于4000元，如果低于4000元，⾃动改为4000元”*/
CREATE TRIGGER Insert_Or_Update_Sal                 /*对教师表插⼊或更新时激活触发器*/ 
       BEFORE INSERT OR UPDATE ON Teacher           /*BEFORE 触发事件*/
       REFERENCING 
            NEW ROW AS newTuple
       FOR EACH ROW                                 /*⾏级触发器*/
BEGIN                                        
/*定义触发动作体，这是一个 PL/SQL 过程块*/
IF (newtuple.Job='教授') AND (newtuple.Sal &lt; 4000) /*⾏级触发器，可在过程体中使用新值*/
THEN newtuple.Sal:=4000；      
/* ":=" 是赋值操作符，将右边的值或结果赋给左边变量。*/
END IF；
END；                                        
/*触发动作体结束*/
</code></pre>
<h3>&lt;font style="color:#DF2A3F;"&gt;激活触发器&lt;/font&gt;</h3>
<p>触发器的执行是由触发事件激活，并由数据库服务器自动执行的。</p>
<p>一个数据表上可能定义了多个触发器，如多个 BEFORE 触发器、多个 AFTER 触发器等， 同一个表上的多个触发器激活时遵循如下的执行顺序：</p>
<ol>
<li>执行该表上的 BEFORE 触发器。</li>
<li>激活触发器的 SQL 语句。</li>
<li>执行该表上的 AFTER 触发器。</li>
</ol>
<p>对于同一个表上的多个 BEFORE / AFTER 触发器，遵循 “谁先创建谁先执行” 的原则（有些遵循字⺟顺序）。</p>
<h3>&lt;font style="color:#DF2A3F;"&gt;删除触发器&lt;/font&gt;</h3>
<p>&lt;font style="color:#F1A2AB;"&gt;语句格式&lt;/font&gt;：【&lt;font style="color:#7E45E8;"&gt;DROP TRIGGER&lt;/font&gt; &lt; 触发器名 &gt; &lt;font style="color:#7E45E8;"&gt;ON&lt;/font&gt; &lt; 表名 &gt; ;】</p>
<p>&lt;font style="color:#81BBF8;"&gt;说明&lt;/font&gt;： 触发器必须是一个已经创建的触发器，并且只能由具有相应权限的用户删除。</p>
<p>触发器功能强大，但使用时要慎重，每次访问一个表时都可能触发一个触发器，这样会影响系统的性能。</p>
<p>···</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>Twikoo 自托管部署：本地 MongoDB 与 Nginx 反向代理</title>
    <link href="https://blog.syrena.cn/posts/twikoo/twikoo/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/twikoo/twikoo/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>本地化MongoDB，让twikoo评论不受网络限制.</summary>
    <content type="html"><![CDATA[<h2>一.必备环境下载</h2>
<h3>1.安装 Node.js 环境</h3>
<ol>
<li>更新系统包索引</li>
</ol>
<pre><code>sudo apt update
</code></pre>
<p>2.添加 NodeSource 仓库</p>
<pre><code>curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
</code></pre>
<ul>
<li>Ubuntu 默认源没有新版本 Node</li>
<li>NodeSource 提供官方维护的高版本 Node</li>
</ul>
<p>3.安装 Node.js</p>
<pre><code>sudo apt install -y nodejs
</code></pre>
<p>4.验证安装</p>
<pre><code>node -v
npm -v
</code></pre>
<p>回显版本号即成功安装</p>
<h3>2.npm 配置</h3>
<p>设置国内镜像，因为默认 npm 源在国外</p>
<pre><code>npm config set registry https://registry.npmmirror.com
</code></pre>
<h3>3.MongoDB 数据库安装</h3>
<ol>
<li>安装 MongoDB</li>
</ol>
<pre><code>sudo apt update
sudo apt install -y mongodb-org
</code></pre>
<p>2.启动 MongDB</p>
<pre><code>sudo systemctl start mongod
sudo systemctl enable mongod
</code></pre>
<p>3.进入 MongoDB Shell</p>
<pre><code>mongosh
</code></pre>
<ol>
<li>创建数据库和用户</li>
</ol>
<pre><code>use twikoo

db.createUser({
  user: "twikoo",
  pwd: "你的密码",
  roles: [
    { role: "readWrite", db: "twikoo" }
  ]
})
</code></pre>
<p><img src="./twikoo2.png" alt="" /></p>
<p>5.验证登录</p>
<pre><code>mongosh -u twikoo -p --authenticationDatabase twikoo
</code></pre>
<p>根据提示内容输入密码即可</p>
<h2>二、部署 tkserver（Twikoo 后端）</h2>
<p>1.全局安装 tkserver（此处需要前面的 Node.js）</p>
<pre><code>sudo npm install -g tkserver
</code></pre>
<p>2.启动 tkserver</p>
<pre><code>MONGODB_URI="mongodb://twikoo:密码@127.0.0.1:27017/twikoo" HOST=127.0.0.1 PORT=8080 tkserver
</code></pre>
<ul>
<li><code>MONGODB_URI</code>：数据库连接字符串</li>
<li><code>HOST=127.0.0.1</code>：只允许本机访问（安全）</li>
<li><code>PORT=8080</code>：服务端口</li>
</ul>
<table>
<thead>
<tr>
<th><strong>名称</strong></th>
<th><strong>描述</strong></th>
<th><strong>默认值</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MONGODB_URI</code></td>
<td>MongoDB 数据库连接字符串，不传则使用 lokijs</td>
<td><code>null</code></td>
</tr>
<tr>
<td><code>MONGO_URL</code></td>
<td>MongoDB 数据库连接字符串，不传则使用 lokijs</td>
<td><code>null</code></td>
</tr>
<tr>
<td><code>TWIKOO_DATA</code></td>
<td>lokijs 数据库存储路径</td>
<td><code>./data</code></td>
</tr>
<tr>
<td><code>TWIKOO_PORT</code></td>
<td>端口号</td>
<td><code>8080</code></td>
</tr>
<tr>
<td><code>TWIKOO_THROTTLE</code></td>
<td>IP 请求限流，当同一 IP 短时间内请求次数超过阈值将对该 IP 返回错误</td>
<td><code>250</code></td>
</tr>
<tr>
<td><code>TWIKOO_LOCALHOST_ONLY</code></td>
<td>为<code>true</code>时只监听本地请求，使得 nginx 等服务器反代之后不暴露原始端口</td>
<td><code>null</code></td>
</tr>
<tr>
<td><code>TWIKOO_LOG_LEVEL</code></td>
<td>日志级别，支持 <code>verbose</code>/ <code>info</code>/ <code>warn</code>/ <code>error</code></td>
<td><code>info</code></td>
</tr>
<tr>
<td><code>TWIKOO_IP_HEADERS</code></td>
<td>在一些特殊情况下使用，如使用了 <code>CloudFlare CDN</code>它会将请求 IP 写到请求头的 <code>cf-connecting-ip</code>字段上，为了能够正确的获取请求 IP 你可以写成 <code>["headers.cf-connecting-ip"]</code></td>
<td><code>[]</code></td>
</tr>
</tbody>
</table>
<p>3.后台运行（即24 小时不间断运行）</p>
<p>方法一：nohup（官方推荐）</p>
<pre><code>nohup env MONGODB_URI="mongodb://twikoo:密码@127.0.0.1:27017/twikoo" HOST=127.0.0.1 PORT=8080 tkserver &gt; /var/log/tkserver.log 2&gt;&amp;1 &amp;
</code></pre>
<p>方法二：systemctl（规范）</p>
<p>创建服务</p>
<pre><code>sudo nano /etc/systemd/system/tkserver.service
</code></pre>
<p>内容：</p>
<pre><code>[Unit]
Description=Twikoo Service
After=network.target

[Service]
Type=simple
Environment="MONGODB_URI=mongodb://twikoo:密码@127.0.0.1:27017/twikoo"
Environment="HOST=127.0.0.1"
Environment="PORT=8080"
ExecStart=/usr/bin/node /usr/lib/node_modules/tkserver/server.js
Restart=always
User=root

[Install]
WantedBy=multi-user.target
</code></pre>
<p>启动：</p>
<pre><code>sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl start tkserver
sudo systemctl enable tkserver
</code></pre>
<h2>三、安装 Nginx</h2>
<ol>
<li>如果没有配置 Nginx，那么：</li>
</ol>
<pre><code>sudo apt install -y nginx
</code></pre>
<p>2.配置站点</p>
<pre><code>sudo nano /etc/nginx/sites-available/twikoo
</code></pre>
<ol>
<li>配置（以我的站点为例）：</li>
</ol>
<p>注意：要将子域名申请 ssl 证书，如果你是阿里云用户可以申请个人测试证书，这样才能通过 https 访问，避免中间人攻击</p>
<p>如图：</p>
<p><img src="./twikoo3.png" alt="" /></p>
<pre><code># HTTP 强制跳 HTTPS
server {
    listen 80;
    server_name twikoo.sirens007.cn;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name twikoo.sirens007.cn;

    ssl_certificate /粘贴你的证书路径.crt;
    ssl_certificate_key /粘贴你的私钥路径.key;

    # TLS 安全增强
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    # 关键：允许评论内容上传
    client_max_body_size 10m;

    location / {
        proxy_pass http://127.0.0.1:8080;

# 基础代理头
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket 支持（Twikoo兼容）
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # 超时优化
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}
</code></pre>
<p>4.启用配置：</p>
<pre><code>sudo ln -s /etc/nginx/sites-available/twikoo /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
</code></pre>
<p>反向代理的目的是为了避免直接暴露 8080，并且浏览器会拦截</p>
<h2>检验部署成功与否</h2>
<p>如果运行出现什么问题，随时查看</p>
<p>1.检查 MongoDB</p>
<pre><code>systemctl status mongod
</code></pre>
<p><img src="./twikoo4.png" alt="" /></p>
<p>2.检查 tkserver 是否运行</p>
<pre><code>sudo systemctl status tkserver
</code></pre>
<p>4.检查 tkserver 是否监听 8080 端口</p>
<pre><code>ss -tlnp | grep 8080
</code></pre>
<p>正常应该得到</p>
<p><img src="./twikoo5.png" alt="" /></p>
<p>5.检查 MongDB 是否监听端口</p>
<pre><code>ss -tlnp | grep 27017
</code></pre>
<p><img src="./twikoo6.png" alt="" /></p>
<p>6.测试接口</p>
<p>浏览器访问：</p>
<pre><code>https://你的站点
</code></pre>
<p><img src="./twikoo7.png" alt="" /></p>
<p>得到如图所示即表示 tkserver 没有问题，以上配置完后即可正常在博客主题中加入对应接口，类似以下：</p>
<pre><code>twikoo: {
		envId: "https://twikoo.sirens007.cn",
		lang: SITE_LANG,
	},
</code></pre>
<p>至此，以上配置完后相应的评论数据可以通过各种可视化数据库查看，相应访问速度也会更快！</p>
<p>参考文章：</p>
<p><a href="https://twikoo.js.org/backend.html#%E7%A7%81%E6%9C%89%E9%83%A8%E7%BD%B2">私有部署 - Twikoo 官方文档</a></p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="博客运维"/>
  </entry>
  <entry>
    <title>Umami 自托管迁移：Docker 部署、Nginx 代理与数据导入</title>
    <link href="https://blog.syrena.cn/posts/umami/umami/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/umami/umami/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>详细部署Umami在你的服务器中，可视化访问数据.</summary>
    <content type="html"><![CDATA[<p>相信我，docker 会比直接裸装数据库要好，因为很大可能你的 Umami build 不动，除非本地构建再上传；就是那样的话本地还得装环境，况且 Umami 官方是支持 docker 部署的</p>
<h2>一、安装 Docker 和 Docker Compose</h2>
<p><strong>1.添加 Docker 官方仓库(镜像)</strong></p>
<pre><code>sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
</code></pre>
<p><strong>2.添加 Docker GPG key 下载，换为阿里云的镜像</strong></p>
<p>Docker 官方要求把 GPG key 存在这个目录，属于新的安全规范（避免污染系统 key）。</p>
<pre><code>sudo mkdir -p /etc/apt/keyrings

curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
</code></pre>
<p><code>https://download.docker.com</code> -&gt; <code>https://mirrors.aliyun.com</code></p>
<p><strong>3.添加 Docker apt 源</strong></p>
<ul>
<li>把 Docker 下载地址从官方改成镜像</li>
<li>以后 <code>apt install docker-ce</code> 就走国内源</li>
</ul>
<pre><code>echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://mirrors.aliyun.com/docker-ce/linux/ubuntu \
$(lsb_release -cs) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
</code></pre>
<p><strong>4.安装 Docker 和 Docker Compse</strong></p>
<p>刚刚添加了新源，必须更新，否则 apt 不知道这个源存在</p>
<pre><code>sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
</code></pre>
<p><strong>5.验证安装完成</strong></p>
<pre><code>docker -v
docker compose version
</code></pre>
<p><strong>6.允许普通用户使用 Docker，避免每一步 sudo</strong></p>
<pre><code>sudo usermod -aG docker $USER
newgrp docker
</code></pre>
<p>Umami 推荐用 Docker Compose 部署，这样 Umami 应用和 PostgreSQL 数据库可以统一管理。</p>
<p><strong>7.创建 Umami 部署目录</strong></p>
<pre><code>sudo mkdir -p /opt/umami
sudo chown -R $USER:$USER /opt/umami
cd /opt/umami
</code></pre>
<p>把 Umami 的配置文件和数据库挂载数据统一放在 <code>/opt/umami</code>，后期维护、备份、迁移更方便。</p>
<h2>二、编写 docker-compose.yml</h2>
<p><strong>切换目录为</strong>：</p>
<pre><code>cd /opt/umami
nano docker-compose.yml
</code></pre>
<p>写入内容如下（：<code>APP_SECRET</code>、<code>POSTGRES_DB</code>、<code>POSTGRES_USER</code>: umami</p>
<pre><code>  `POSTGRES_PASSWORD`可自行更改
</code></pre>
<pre><code>services:
  umami:
    image: ghcr.io/umami-software/umami:latest
    container_name: umami
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: postgresql://umami:umami_password@db:5432/umami
      APP_SECRET: your_random_app_secret
    depends_on:
      db:
        condition: service_healthy
    restart: always

  db:
    image: postgres:15-alpine
    container_name: umami-db
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: umami_password
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
      interval: 5s
      timeout: 5s
      retries: 5
</code></pre>
<p><strong>生成</strong>  <code>APP_SECRET</code>：</p>
<pre><code>openssl rand -base64 32
</code></pre>
<p><code>DATABASE_URL</code> 是 Umami 连接 PostgreSQL 的地址。<br />
<code>APP_SECRET</code> 用于加密会话，不能随便写固定弱字符串。<br />
<code>127.0.0.1:3000:3000</code> 表示 Umami 只允许本机访问，外部访问交给 Nginx 反代，更安全。</p>
<h2>三、启动 Umami</h2>
<p><strong>1. 以下命令是在/opt/umami 下执行，否则会找不到 docker-compose.yml</strong></p>
<pre><code>cd /opt/umami
sudo docker compose up -d
</code></pre>
<p><strong>2. 查看容器</strong>：</p>
<pre><code>sudo docker compose ps
</code></pre>
<p><strong>3. 查看日志</strong>：</p>
<pre><code>sudo docker compose logs -f umami
</code></pre>
<p>确认 Umami 和 PostgreSQL 是否正常运行，尤其是数据库，像图片中就是正常运行了</p>
<p><img src="./umami2.png" alt="" /></p>
<p><strong>4.测试本地访问</strong></p>
<pre><code>curl -I https://127.0.0.1:3000
</code></pre>
<p>正常是可以得到<code> HTTP/1.1 200 OK</code>此类回显；</p>
<p>我们的顺序是先确认 Umami 本身在服务器内部可访问，再配置域名和 HTTPS</p>
<h2>四、配置 Nginx 反向代理</h2>
<p><strong>1.创建配置</strong>：</p>
<pre><code>sudo nano /etc/nginx/sites-available/umami
</code></pre>
<p>写入：</p>
<p>以下内容要 <strong>&lt;font style="color:#DF2A3F;"&gt;注意更改证书路径&lt;/font&gt;</strong></p>
<pre><code>server {
    listen 80;
    server_name umami.sirens007.cn;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name umami.sirens007.cn;

    ssl_certificate /path/to/your.crt;
    ssl_certificate_key /path/to/your.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://127.0.0.1:3000;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
</code></pre>
<p>启用：</p>
<pre><code>sudo ln -s /etc/nginx/sites-available/umami /etc/nginx/sites-enabled/umami
sudo nginx -t
sudo systemctl reload nginx
</code></pre>
<p>是因为 Docker 里的 Umami 不直接暴露公网，而是通过 Nginx 提供 HTTPS 访问，后期方便配置证书、缓存...</p>
<p>注意，在访问并登录 Umami 前，<strong>先申请 SSL 证书</strong>以便支持 https 协议，如果是阿里云用户可</p>
<p>以在阿里云中申请个人测试证书，要记得三个月一换</p>
<p>申请后可以在阿里云的工作台<strong>一键上传证书和私钥，记得上传</strong></p>
<p><img src="./umami3.png" alt="" /></p>
<p>随后便可以登录 Umami：</p>
<p>直接在浏览器访问<code>https://umami.sirens007.cn</code>，改为你的域名即可</p>
<p>默认账号密码为：</p>
<blockquote>
<p>用户名：admin</p>
<p>密码：umami</p>
</blockquote>
<p>登录后立刻修改密码，否则你的账号可能被别人登录并改密</p>
<h2>五、导入 Umami 访问数据</h2>
<p><strong>1. 在 Umami 后台添加你的网站</strong>，比如<code>blog.sirens007.cn</code></p>
<p>创建一条 website，随后就可以得到 shareId，并且获取统计脚本，例如</p>
<pre><code>&lt;script
  defer
  src="https://umami.sirens007.cn/script.js"
  data-website-id="新的 website_id"&gt;
&lt;/script&gt;
</code></pre>
<p>为什么导入放在后面，因为可以让你的网站能拿到最新的访问数据...</p>
<p>开个玩笑，其实就是为了在能够拿到 website_id 后，才需要对导出的数据文件操作</p>
<p><strong>2.访问</strong> <a href="https://cloud.umami.is/settings/data">&lt;u&gt;&lt;font style="color:rgb(30, 30, 30);"&gt;Umami Cloud Data | Settings&lt;/font&gt;&lt;/u&gt;</a>&lt;font style="color:rgb(31, 31, 31);"&gt; ，&lt;/font&gt;<strong>&lt;font style="color:rgb(31, 31, 31);"&gt;选择导出数据&lt;/font&gt;</strong></p>
<p><img src="./umami4.png" alt="" /></p>
<p>随后会在你注册的邮箱中收到一个 zip 压缩包，其中只有 website_event.csv 这个文件有数据，其他两个只有表头</p>
<p>不知道你是一位喜欢 CLI 界面还是 GUI 界面的爱好者，如果是喜欢命令行的话在服务器用查询语句就可以查到 umami 这个仓库下的所有字段；如果喜欢图形化，那么 Navicat 可能是你需要的，或者&lt;font style="color:rgb(31, 31, 31);"&gt; &lt;/font&gt;<a href="https://www.pgadmin.org/">&lt;u&gt;&lt;font style="color:rgb(30, 30, 30);"&gt;pgAdmin - PostgreSQL Tools&lt;/font&gt;&lt;/u&gt;</a>。类似下图中可以看到各个字段的顺序，同时也可以修改</p>
<p><img src="./umami5.png" alt="" /></p>
<p>此时你可能还需要一个能够修改 csv 文件的一个软件，不推荐 wps 和 excel，首先 excel 可能是 CRLF 文件类型问题，wps 在你编辑后需要转为 xlsx 文件，所以还是奉劝下一个&lt;font style="color:rgb(31, 31, 31);"&gt;CSV编辑软件： &lt;/font&gt;<a href="https://smoothcsv.com/">&lt;u&gt;&lt;font style="color:rgb(30, 30, 30);"&gt;SmoothCSV - The ultimate CSV editor for macOS &amp; Windows&lt;/font&gt;&lt;/u&gt;</a>&lt;font style="color:rgb(30, 30, 30);"&gt;  &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(30, 30, 30);"&gt;在我在的版本 &lt;/font&gt;<strong>&lt;font style="color:rgb(30, 30, 30);"&gt;Umami3.1&lt;/font&gt;</strong>&lt;font style="color:rgb(30, 30, 30);"&gt; 中，&lt;/font&gt;<strong>&lt;font style="color:rgb(30, 30, 30);"&gt;website_event.csv 文件中有 41 行数据&lt;/font&gt;</strong>&lt;font style="color:rgb(30, 30, 30);"&gt;，而 PostgreSQL 数据库中的 &lt;/font&gt;<strong>&lt;font style="color:rgb(30, 30, 30);"&gt;website_event 这个表中只有 31 条&lt;/font&gt;</strong>&lt;font style="color:rgb(30, 30, 30);"&gt;，因此如果要导入该文件&lt;/font&gt;<strong>&lt;font style="color:rgb(30, 30, 30);"&gt;需要删除 10 条数据库中不存在的字段的数据&lt;/font&gt;</strong>&lt;font style="color:rgb(30, 30, 30);"&gt;，但是要先备份该文件，因为后 10 条数据实际上是放在数据库中的 session 这个表中的，因此将备份的文件删掉 event 表中的 31 列数据后并排好序就可导入&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(30, 30, 30);"&gt;website_event.csv 该文件中的 event_id 需要&lt;/font&gt;<strong>&lt;font style="color:rgb(30, 30, 30);"&gt;修改为服务器部署的 Umami 增设的 website 新获得的 websiteId&lt;/font&gt;</strong>&lt;font style="color:rgb(30, 30, 30);"&gt;，只有此处有修改，剩下的只需要排序即可&lt;/font&gt;</p>
<p><img src="./umami6.png" alt="" /></p>
<p><strong>3. 随后使用 scp 命令上传至服务器即可</strong></p>
<pre><code>scp website_event.csv session.csv root@服务器IP:/home/sirens/
</code></pre>
<p><strong>4. 然后复制进 PostgreSQL 容器</strong>：</p>
<pre><code>sudo docker cp /home/root/website_event.csv umami-db:/tmp/website_event.csv
sudo docker cp /home/root/session.csv umami-db:/tmp/session.csv
</code></pre>
<p>主要是因为<code>psql \copy</code> 在容器里执行时，读取的是容器内部路径，所以要先把 CSV 放进容器的 <code>/tmp</code></p>
<p><strong>5. 随后进入 PostgreSQL</strong>：</p>
<pre><code>sudo docker compose exec db psql -U umami -d umami
</code></pre>
<p>-U 代表用户 Users，-d 代表 database</p>
<p>数据导入、查看表结构、删除约束、恢复约束都要在 PostgreSQL 里完成或者在 Navicat 中完成；<strong>&lt;font style="color:#DF2A3F;"&gt;在导入前，最后检查一遍是否与数据库中的字段顺序对应上&lt;/font&gt;</strong></p>
<p><strong>6. 查看</strong> <code>website_event</code>：</p>
<pre><code>SELECT ordinal_position, column_name, data_type
FROM information_schema.columns
WHERE table_name = 'website_event'
ORDER BY ordinal_position;
</code></pre>
<p><strong>6. 查看</strong> <code>session</code>：</p>
<pre><code>SELECT ordinal_position, column_name, data_type
FROM information_schema.columns
WHERE table_name = 'session'
ORDER BY ordinal_position;
</code></pre>
<p>注意可能输出的字段按页折叠，记得检查完全</p>
<p>重要步骤！！！</p>
<p><strong>7. 迁移前先备份数据库</strong></p>
<pre><code>sudo docker compose exec db pg_dump -U umami -d umami &gt; /opt/umami/backup_before_import.sql
</code></pre>
<p><strong>8.再检查一下本地 session 和 website_event 字段顺序</strong></p>
<p>检查 website_event：</p>
<pre><code>SELECT ordinal_position, column_name, data_type
FROM information_schema.columns
WHERE table_name = 'website_event'
ORDER BY ordinal_position;
</code></pre>
<p>然后根据实际字段写导入语句，例如常见结构可能类似：</p>
<pre><code>\copy website_event (
  event_id,
  website_id,
  session_id,
  created_at,
  url_path,
  url_query,
  referrer_path,
  referrer_query,
  referrer_domain,
  page_title,
  event_type,
  event_name,
  visit_id
)
FROM '/tmp/website_event.csv'
WITH (FORMAT csv, HEADER true, NULL '\N', QUOTE '"');
</code></pre>
<pre><code># 建议执行单条命令，如上条报错
sudo docker compose exec db psql -U umami -d umami -c "\copy session (session_id, website_id, browser, os, device, screen, language, country, region, city, created_at, distinct_id) FROM '/tmp/session.csv' WITH (FORMAT csv, HEADER true, NULL '\N', QUOTE '\"');"
</code></pre>
<p>注意：<br />
这条不能直接照抄，必须以你本地 <code>information_schema.columns</code> 查到的字段为准；</p>
<p>原因：<br />
Umami Cloud 导出的 CSV 字段和本地 v3.x 表结构可能不同。多字段、少字段、顺序不一致都会导入失败；</p>
<hr />
<p><strong>检查 session 本地字段：</strong></p>
<pre><code>SELECT column_name
FROM information_schema.columns
WHERE table_name = 'session'
ORDER BY ordinal_position;
</code></pre>
<p>然后导入时必须这样写成一整条命令，不要在 <code>psql</code> 交互里分开敲：</p>
<pre><code>\copy session (
  session_id,
  website_id,
  browser,
  os,
  device,
  screen,
  language,
  country,
  region,
  city,
  created_at,
  distinct_id
)
FROM '/tmp/session.csv'
WITH (FORMAT csv, HEADER true, NULL '\N', QUOTE '"');
</code></pre>
<p>如果你是在 shell 里执行，推荐：</p>
<pre><code>sudo docker compose exec db psql -U umami -d umami -c "\copy session (session_id, website_id, browser, os, device, screen, language, country, region, city, created_at, distinct_id) FROM '/tmp/session.csv' WITH (FORMAT csv, HEADER true, NULL '\N', QUOTE '\"');"
</code></pre>
<p>原因：</p>
<p>就是我之前有报错<code>syntax error at or near "session_id"</code></p>
<p><strong>9.重启 Umami</strong></p>
<pre><code>sudo docker compose restart umami
</code></pre>
<p>最后访问后台 umami.example.com 登录检查数据是否对应即可</p>
<p>总流程顺序：</p>
<pre><code>1. 安装 Docker
2. 创建 /opt/umami
3. 写 docker-compose.yml
4. 启动 Umami + PostgreSQL
5. 配置 Nginx + HTTPS
6. 登录后台并修改密码
7. 创建网站
8. 导出 Umami Cloud 数据
9. 上传 CSV 到服务器
10. 备份本地数据库
11. 查看本地表结构
12. 修正 CSV 字段和 website_id
13. 导入 session
14. 导入 website_event
15. 检查数据关联
16. 恢复约束
17. 重启 Umami
18. 修改博客统计脚本
19. 后台验证统计结果
</code></pre>
<p>参考文章：</p>
<p><a href="https://blog.2x.nz/posts/umami-migration/">Umami Cloud迁移到本地踩坑记录</a></p>
<p><a href="https://docs.umami.is/docs/install">Installation</a></p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="博客运维"/>
  </entry>
  <entry>
    <title>Java 对象比较的三种方式：equals、Comparable 与 Comparator</title>
    <link href="https://blog.syrena.cn/posts/objectsofcomparation/objectsofcomparation/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/objectsofcomparation/objectsofcomparation/</id>
    <published>2026-04-14T00:00:00.000Z</published>
    <updated>2026-04-14T00:00:00.000Z</updated>
    <summary>通过示例分析 Java 对象比较的三种实现方式，包括 equals 重写、自然排序与自定义比较器.</summary>
    <content type="html"><![CDATA[<p>我们就拿这个来举例：</p>
<pre><code>public class Card{
    public String suit;
    public int rank;

    public Card(String suit,int rank) {
        this.suit = suit;
        this.rank = rank;
    }
    
    public static void main(String[] args) {
        Card card1 = new Card("hongtao", 5);
        Card card2 = new Card("heitao", 5);
}
</code></pre>
<h2>1.覆写基类的 equals</h2>
<p>当我们要判断<strong>两张牌是否相同</strong>时，需要比较的内容是 <strong>suit 以及 rank 两个属性</strong>。</p>
<p>因此在比较是否相同上，我们通常用<code>==</code>进行比较，而在两个实例化的 card 中，则使用 <code>euqals</code>方法。</p>
<pre><code>public class Card {
    public String suit;
    public int rank;

    public Card(String suit,int rank) {
        this.suit = suit;
        this.rank = rank;
    }
    // 比较牌面、花色是否相同
    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        Card card = (Card) o;
        return rank == card.rank &amp;&amp; Objects.equals(suit, card.suit);
    }

    @Override
    public int hashCode() {
        return Objects.hash(suit, rank);
    }
}
</code></pre>
<p><strong>重写 equals、hashCode 方法</strong></p>
<p>在 IDEA 上，可以使用快捷键<code>Alt+Ins</code>，找到 <code>equals 和 hashCode</code>默认下一步即可</p>
<h2>2.基于 Comparable 接口类的比较</h2>
<p>对用用户自定义类型，如果要想按照大小与方式进行比较时：在定义类时，实现<code>Comparable</code>接口即可，然后在类中<strong>重写</strong><code>compareTo</code><strong>方法</strong>。</p>
<pre><code>public class Card implements Comparable&lt;Card&gt; {
    public String suit;
    public int rank;

    public Card(String suit,int rank) {
        this.suit = suit;
        this.rank = rank;
    }
    // 大小比较
    @Override
    public int compareTo(Card o) {
        return this.rank - o.rank;
    }
}
</code></pre>
<p>因此我们在只<strong>比较牌面大小</strong>的时候，可以通过<strong>重写</strong><code>compareTo</code>方法即可。</p>
<p>其 return 有两种写法</p>
<pre><code>return this.rank - o.rank;
return Integer.compare(this.rank, o.rank);  //  更安全
</code></pre>
<p>该方法的返回值含义有以下：</p>
<pre><code>public interface Comparable&lt;E&gt; {
// 返回值:
// &lt; 0: 表示 this 指向的对象小于 o 指向的对象
// == 0: 表示 this 指向的对象等于 o 指向的对象
// &gt; 0: 表示 this 指向的对象大于 o 指向的对象
int compareTo(E o);
}
</code></pre>
<h2>3.基于比较器比较</h2>
<p>当用户<strong>自定义比较器</strong>的时候，实现这个接口并<strong>重写 compare 方法</strong></p>
<p>例如，在该例子中我想<strong>比较花色</strong>，我们可以在 Card 类之外再<strong>创建一个类去实现该接口</strong>。减少对类的嵌入</p>
<p><code>comapre 方法</code>内如何比较可自定义</p>
<pre><code>class SuitComparator implements Comparator&lt;Card&gt; {
    public int compare(Card o1,Card o2) {
        return o1.suit.compareTo(o2.suit);
    }
}
</code></pre>
<p><strong>注意</strong>：Comparator 是 java.util 包中的泛型接口类，使用时必须导入对应的包。</p>
<p>该比较器的方法返回值如下：</p>
<pre><code>public interface Comparator&lt;T&gt; {
// 返回值:
// &lt; 0: 表示 o1 指向的对象小于 o2 指向的对象
// == 0: 表示 o1 指向的对象等于 o2 指向的对象
// &gt; 0: 表示 o1 指向的对象等于 o2 指向的对象
int compare(T o1, T o2);
}
</code></pre>
<h2>三种方式的对比</h2>
<table>
<thead>
<tr>
<th><strong>特性</strong></th>
<th><strong>Object.equals</strong></th>
<th><strong>Comparable</strong></th>
<th><strong>Comparator</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>比较目的</strong></td>
<td>判断相等</td>
<td>定义默认顺序</td>
<td>定义多种/临时顺序</td>
</tr>
<tr>
<td><strong>比较结果</strong></td>
<td>boolean</td>
<td>int (负, 0, 正)</td>
<td>int (负, 0, 正)</td>
</tr>
<tr>
<td><strong>修改位置</strong></td>
<td>类内部</td>
<td>类内部（实现接口）</td>
<td>类外部（独立比较器）</td>
</tr>
<tr>
<td><strong>灵活性</strong></td>
<td>低</td>
<td>中（一种规则固定）</td>
<td>高（多种规则切换）</td>
</tr>
<tr>
<td><strong>典型方法</strong></td>
<td><code>a.equals(b)</code></td>
<td><code>a.compareTo(b)</code></td>
<td><code>c.compare(a, b)</code></td>
</tr>
</tbody>
</table>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>七种经典排序算法：实现思路、复杂度与适用场景</title>
    <link href="https://blog.syrena.cn/posts/sort/sort/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/sort/sort/</id>
    <published>2026-04-14T00:00:00.000Z</published>
    <updated>2026-04-14T00:00:00.000Z</updated>
    <summary>汇总常见七种排序算法的基本思想、实现过程与时间复杂度，并结合代码示例对比不同排序方法的适用场景.</summary>
    <content type="html"><![CDATA[<h2>1. 插入排序</h2>
<p><strong>插入排序过程：</strong></p>
<ol>
<li>定义 tmp 变量存储<code>array[i]</code>当前数值，定义<code>j = i - 1</code>变量</li>
<li>利用循环将当前<code>array[i]</code>去遍历数组前<code>[0,j]</code>元素比较大小。若<code>tmp</code>有比前<code>[0,j]</code>元素要小，则<code>array[j]</code>赋值给<code>array[j+1]</code>；若<code>tmp</code>大于<code>[0,j]</code>中某元素，则<code>array[j+1] = tmp</code></li>
<li>随后 j- -，完成循环</li>
</ol>
<h3>1.1 插入排序实例</h3>
<p><strong>举例排序过程如下：</strong>
<img src="./images/Sort1.webp" alt="" /></p>
<p><strong>具体动图如下</strong>：
<img src="./images/Sort2.gif" alt="" /></p>
<p>实现代码如下：</p>
<pre><code>/**
* 直接插入排序
* 时间复杂度：O(n^2)
* 空间复杂度：O(1)
*/ 
public static void insertSort(int[] array) {
	for (int i = 1; i &lt; array.length; i++) {
    	// 定义临时变量进行存储、为下方提供比较对象
        int tmp = array[i];
        int j = i - 1;
        // 边找位置边移动
        while (j &gt;= 0 &amp;&amp; array[j] &gt; tmp) {
        	// 将当前值后移
            array[j + 1] = array[j];
            j--;
        }
        // 插入到正确位置
        array[j + 1] = tmp;
    }
}
</code></pre>
<h3>1.2 复杂度：</h3>
<p>无论其是否有序、无序，其<strong>时间复杂度</strong>都为 <strong>O(N^2)</strong>。</p>
<p>稳定性：稳定</p>
<p>因为其首先<strong>利用 i 遍历数组上每一个元素</strong>(不钻牛角尖第一个)，<strong>随后的 j 还要遍历一段去比较 tmp</strong>，属于<strong>嵌套循环</strong>，因此无论在逻辑上还是代码上都是 <strong>O(N^2)</strong>，而空间复杂度则为 O(1)，所创建的变量是有限个的</p>
<h2>2. 希尔排序</h2>
<p>1959年，<strong>唐纳德·希尔</strong>（<strong>Donald Shell</strong>）提出了一个改进思路：</p>
<p>先让数组<strong>基本有序</strong>，再用插入排序完成最终排序。</p>
<p>希尔排序即，缩小增量排序，其<strong>具体思想</strong>为：</p>
<ol>
<li>先选定一个整数 <strong>gap</strong>（称为增量），把待排序文件中的所有记录分成 <strong>gap 组</strong>，所有距离为 gap 的记录分在同一组内；</li>
<li>对<strong>每一组内的记录进行直接插入排序</strong>；</li>
<li>然后<strong>缩小增量</strong>（如 `gap = gap / 2），<strong>重复</strong>上述分组和排序的工作；</li>
<li>当 <strong>gap = 1</strong> 时，所有记录在同一组内，此时再执行一次直接插入排序，整个序列就排好序了。</li>
</ol>
<h3>2.1 希尔排序实例</h3>
<p>举例排序过程如下：</p>
<p>初始 gap = 5；缩小量为 gap = gap/2</p>
<p>数据如下
<img src="./images/Sort3.webp" alt="" /></p>
<p>排序元素为<strong>对应颜色指向的元素下</strong>进行排序，得到如下数组
<img src="./images/Sort4.webp" alt="" /></p>
<p>随后 gap = 2；此时每隔两个空间设为一个组类
<img src="./images/Sort5.webp" alt="" /></p>
<p>排序后的数组为：
<img src="./images/Sort6.webp" alt="" /></p>
<p>此后 gap  = 1；此时数组统一进行一次大排序，将进行直接插入操作，因此得到
<img src="./images/Sort7.webp" alt="" /></p>
<p><strong>具体动图过程如下：</strong></p>
<p>上 gif 动图中 gap = 2 时直接跳过变换过程，</p>
<p><img src="./images/Sort8.gif" alt="" /></p>
<p>实现代码如下：</p>
<pre><code>// 希尔排序
public static void shellSort(int[] array) {
    int gap = array.length;
    while(gap &gt; 1) {
        gap = gap/2;
        shell(array,gap);
    }
}
private static void shell(int[] array,int gap) {
    for (int i = gap; i &lt; array.length; i++) {
        int tmp = array[i];
        int j = i - gap;
        // 以下部分与插入排序相同，仅将1改为gap
        while(j &gt;= 0 &amp;&amp; array[j] &gt; tmp) {
            array[j + gap] = array[j];
            j -= gap;
        }
        array[j + gap] = tmp;
    }
}
</code></pre>
<h3>2.2 复杂度：</h3>
<p>在学术方面，对于希尔排序的时间复杂度一直有多种说法，也一直尚未有准确的时间复杂度。比如：
<img src="./images/Sort9.webp" alt="" /></p>
<p>以及
<img src="./images/Sort10.webp" alt="" /></p>
<p>根据对书籍总结，我们可以将其时间复杂度视为  <strong>O(N^1.25)</strong> 到  <strong>O(1.6*N^1.25)</strong> 来算</p>
<h2>3. 选择排序</h2>
<p><strong>选择排序过程：</strong></p>
<ol>
<li>先假设第一个元素为最小元素，暂存其下标</li>
<li>遍历剩余元素是否有比最小元素还小的元素</li>
<li>如有，则在遍历完一轮后交换；若无，则第一层的下标后移一位，假设第二个元素为最小元素，暂存其下标...以此重复</li>
</ol>
<h3>3.1 选择排序实例</h3>
<p><strong>举例排序过程如下：</strong>
<img src="./images/Sort11.webp" alt="" /></p>
<p>由第一层的 i 暂定最小值，第二层的 j 遍历寻找是否有更小的元素</p>
<p><strong>具体动图如下</strong>：
<img src="./images/Sort12.gif" alt="" /></p>
<p>实现代码如下：</p>
<pre><code>    /**
     * 选择排序
     * 时间复杂度：
     * 空间复杂度：
     * 稳定性：
     * @param array
     */
public static void selectSort(int[] array) {
    for (int i = 0; i &lt; array.length; i++) {
        // 定义最小元素下标
        int minIndex = i;
        // 向后遍历数组，寻找小于最小元素的元素
        for (int j = i + 1; j &lt; array.length; j++) {
            if (array[j] &lt; array[minIndex])
                minIndex = j;
        }
        swap(array,minIndex,i);
    }
}

/**
 * @param array 数组
 * @param i 下标
 * @param j 要换的元素的下标
 */
public static void swap(int[] array,int i,int j) {
    int tmp = array[j];
    array[j] = array[i];
    array[i] = tmp;
}
</code></pre>
<p>但上述代码存在优化，优化内容为：</p>
<ol>
<li>最大最小值可同时找，可减少第二层遍历次数</li>
</ol>
<p>优化后的过程如下图所述：</p>
<p>定义 left 与 right 为数组两端，i 为中间元素下标在 for 循环中游走，同时比较最大最小值
<img src="./images/Sort13.webp" alt="" /></p>
<p><strong>注意</strong>：当<strong>默认最小值为实际最大值</strong>的时候，在<strong>交换最小值过程</strong>中<strong>实际最大值</strong>已被换走，而当<strong>最大值交换过程</strong>中<strong>实际最大值</strong>已被换为<strong>实际最小值</strong>，如图中所举案例 minIndex 要与 left 换，但 left（maxIndex）同时还要与 right 换，因此我们需要在此判断一下，<code>if (maxIndex == left) maxIndex = minIndex;</code></p>
<p>完整代码如下：</p>
<pre><code>public static void selectSortPlus(int[] array) {
    int left = 0;
    int right = array.length - 1;
    while (left &lt; right) {
        int minIndex = left;
        int maxIndex = left;
        // 遍历中间元素，同时找最大最小值
        for (int i = left + 1; i &lt;= right; i++) {
            if (array[i] &lt; array[minIndex])
                minIndex = i;
            if (array[i] &gt; array[maxIndex])
                maxIndex = i;
        }
        swap(array,left,minIndex);
        // 最大值正好是 left下标，此时已经被上一步换到minIndex了
        if (maxIndex == left) maxIndex = minIndex;
        swap(array,right,maxIndex);
        left++;
        right--;
    }
}

/**
 * @param array 数组
 * @param i 下标
 * @param j 要换的元素的下标
 */
public static void swap(int[] array,int i,int j) {
    int tmp = array[j];
    array[j] = array[i];
    array[i] = tmp;
}
</code></pre>
<h3>3.2 复杂度：</h3>
<p>对于未优化的版本：</p>
<p><strong>外层循环</strong>需要执行 n - 1 次（i 从 0 到 n - 2）；</p>
<p><strong>内层循环</strong>比较次数从<strong>第一轮</strong>到<strong>第 n - 1 轮</strong>：n - 1 次、n - 2 次、n - 3 次......1 次</p>
<p>由等差数列求和可约等于<code> n^2/2</code>，即时间复杂度为 O(N^2)、空间复杂度显然为 O(1)</p>
<p>对于优化版本的：</p>
<p><strong>外层循环</strong>需执行的次数：n/2 轮</p>
<p>每层<strong>内循环</strong>需要遍历<code>right - left</code>个元素，我们用归纳法</p>
<p>第 1 轮：n - 1 次；第二轮：n - 3 次；第三轮：n - 5 次......最后一轮：1 次；</p>
<p>综上为公差为 2 的等差数列，最总计算约为 n^2/4；时间复杂度同样为 O(N^2)、空间复杂度同样显然为 O(1)</p>
<h2>4. 堆排序</h2>
<p>将数组视为二叉树</p>
<p><strong>堆排序过程：</strong></p>
<ol>
<li>建大根堆</li>
<li>反复将堆顶（最大值）放到末尾</li>
<li>剩余部分重新调整为大根堆</li>
<li>重复直到全部有序</li>
</ol>
<p>最终结果：升序数组，天然满足小根堆性质</p>
<h3>4.1 堆排序实例</h3>
<p><strong>举例排序过程如下：</strong></p>
<p>例:集合{ 27,15,19,18,28,34,65,49,25,37 }</p>
<p><code>createHeap</code> 调用 <code>siftDown</code> 调整为大根堆过程如下:
<img src="./images/Sort14.webp" alt="" /></p>
<p>我们要从<strong>最后一个非叶子节点</strong>下手,因此我们选定 4 下标与其子树进行比较,因此 4 下标为 parent 索引,9 下标则为 child 索引</p>
<ol>
<li>child &gt; parent 则交换,child = parent 赋值,parent = (parent -1)/2 向上走</li>
<li>此刻 child &gt; parent,重复步骤一随后得到 parent 为 0 索引</li>
</ol>
<p>以上<strong>为一轮 for 循环</strong>,因此我们只需要<strong>对非叶子节点从后往前</strong>一步步循环换上去即可</p>
<p><strong>具体上述过程可看前篇大根堆小根堆篇</strong></p>
<p><code>heapSort</code> 过程如下：
<img src="./images/Sort15.webp" alt="" /></p>
<p>将堆顶（最大值）放到末尾，剩余部分重新调整为大根堆，依次进行最终调整为升序数组</p>
<p><strong>具体动图如下：</strong>
<img src="./images/Sort16.gif" alt="" /></p>
<p><strong>实现代码如下：</strong></p>
<pre><code>/**
 * 堆排序
 * 时间复杂度：O(NlogN)
 * 空间复杂度：O(1)
 * 稳定性：不稳定
 * @param array
 */
public static void heapSort(int[] array) {
    createHeap(array);
    int end = array.length - 1;
    while (end &gt; 0) {
        // 反复将堆顶（最大值）放到末尾
        swap(array,0,end);
        // 剩余部分重新调整为大根堆
        siftDown(array,0,end);
        end--;
    }
}
// 创建大根堆
private static void createHeap(int[] array) {
    for (int parent = (array.length-2)/2; parent &gt;= 0; parent--) {
        siftDown(array,parent,array.length);    // parent逐步-1 根节点往上走
    }
}
/**
 * @param parent 每棵子树调整的时候 的 起始位置
 * @param length 判断 每棵子树什么时候 调整 结束
 */
// 大根堆 -- 每棵树的根节点向下调
private static void siftDown(int[] array,int parent,int length) {
    int child = 2 * parent + 1; // 求得左子节点所在位
    while(child &lt; length) {   //
        if(child + 1 &lt; length &amp;&amp; array[child] &lt; array[child + 1]) {
            child++;    // 若右子树存在且小于左子树，则找到右子树
        }
        if(array[child] &gt; array[parent]) {    // 如果于它的根，则换
            swap(array,child, parent);
            parent = child;     // 根节点向下移动，循环将下面的排好
            child = 2 * parent + 1; // 找根节点下面的左子树
        } else {
            break;  // 跳出循环
        }
    }
}

public static void swap(int[] array,int i,int j) {
    int tmp = array[j];
    array[j] = array[i];
    array[i] = tmp;
}
</code></pre>
<h3>4.2 复杂度：</h3>
<p>建堆<code>createHeap()</code>时间复杂度：
<img src="./images/Sort17.webp" alt="" /></p>
<p>排序阶段<code>heapSort()</code>：</p>
<pre><code>while (end &gt; 0) {
    swap(array, 0, end);        // O(1)
    siftDown(array, 0, end);    // O(log n)
    end--;
}
</code></pre>
<p>循环次数为 n-1 次；每次 siftDown 的时间复杂度为<strong>O(log n)</strong>（堆的高度），则<strong>总时间复杂度 = O(n log n)</strong>。</p>
<p>两部分的<strong>总时间复杂度</strong>则为：<strong>O(N logN)</strong><br />
<strong>空间复杂度为 O(1)</strong></p>
<h2>5. 冒泡排序</h2>
<p>冒泡排序过程：</p>
<ol>
<li>相邻元素两两比较，如果前面比后面大，就交换</li>
<li>每一轮结束后，当前未排序部分的最大值会“沉”到末尾</li>
<li>重复直到全部有序</li>
</ol>
<h3>5.1 冒泡排序实例</h3>
<p><strong>具体动图如下：</strong>
<img src="./images/Sort18.gif" alt="" /></p>
<p>该图为最终优化版代码：</p>
<pre><code>/**
 * 冒泡排序：
 * 时间复杂度：O(N^2)
 * 空间复杂度：O(1)
 * 稳定性：稳定
 * @param array
 */
public static void bubbleSort(int[] array) {
    for (int i = 0; i &lt; array.length - 1; i++) {
        boolean flg = false;
        for (int j = 0; j &lt; array.length - 1 - i; j++) {
            if(array[j] &gt; array[j + 1]) {
                swap(array,j,j + 1);
                flg = true;
            }
        }
        if (!flg) break;
    }
}
</code></pre>
<p>具体优化处：</p>
<ol>
<li>增加 flg 判断是否已有序，从而提前退出循环</li>
<li><code>j &lt; array.length - 1 - i;</code>减少对比次数，无需多余对比已排好元素</li>
</ol>
<h3>5.2 复杂度：</h3>
<p>时间复杂度：</p>
<p><strong>最好情况</strong>：当数组有序时，时间复杂度为 O(N)</p>
<p><strong>最坏情况</strong>：当数组逆序时，时间复杂度为 O(N^2)</p>
<p><strong>平均情况</strong>：O(N^2)</p>
<p>空间复杂度为 O(1)</p>
<p>稳定性：稳定</p>
<h2>6. 快速排序</h2>
<p>快速排序是 Hoare 于 1960 年提出的一种二叉树结构的交换排序方法，其基本思想为：<strong>任取待排序元素序列中的某元素作为基准值，按照该排序码将待排序集合分割成两子序列，左子序列中所有元素均小于基准值，右子序列中所有元素均大于基准值，然后最左右子序列重复该过程，直到所有元素都排列在相应位置上为止。</strong></p>
<hr />
<p><strong>快排的总体思路：</strong></p>
<ol>
<li>选一个基准值</li>
<li>将数组分成两部分：左边都 ≤ 基准，右边都 ≥ 基准</li>
<li>对左右两部分重复上述过程</li>
</ol>
<h3>6.1 快排方法</h3>
<h4>6.1.1 递归框架</h4>
<p>根据上述排序说明，可以列出以下<strong>递归框架</strong>：</p>
<p>该递归框架可用于<strong>Hoare</strong>、<strong>挖坑</strong>、<strong>双指针法</strong></p>
<pre><code>public static void quickSort(int[] array) {
    quick(array,0,array.length - 1);
}

void quick(int[] array, int left, int right) {
    // 递归终止条件：区间内没有元素或只有一个元素
    if (left &gt;= right) {
        return;
    }
    // 分区操作：将数组分成两部分，并返回基准元素的最终位置
    int pivot = partition(array, left, right);
    // 递归排序左半部分：[left, pivot]
    quick(array, left, pivot);

    // 递归排序右半部分：[pivot+1, right]
    quick(array, pivot + 1, right);
}
</code></pre>
<hr />
<p>递归框架下的<strong>复杂度</strong>说明：</p>
<p>​        当数组为有序(1，2，3，4，5...)或逆序(5，4，3，2，1...)时候，<strong>时间复杂度</strong>达到了<strong>O(N^2)</strong>；最好情况是数组拆分时<strong>满足满二叉树</strong>时候，<strong>时间复杂度</strong>达到 <strong>O(N logN)</strong>，具体计算是由于拆分后每层相加<strong>总个数都为 N</strong>，二叉树<strong>高度为 log N</strong>。</p>
<p>​        而<strong>空间复杂度</strong>最坏可达到 <strong>O(N)<strong>因为</strong>递归需要开辟内存</strong>，并且为逆序是二叉树为<strong>单枝二叉树</strong>，节点为 N 个则需要递归 N 次；最好情况还是<strong>满二叉树时候复杂度达到 O(logN)</strong>，因为树的高度为 logN，当递归完左侧之后再递归右侧时，<strong>左侧的空间就会释放掉</strong>。</p>
<p><strong>注意：以下方法在介绍时不区分递归与否，递归与否与下方分区方法无关</strong></p>
<h4>6.1.2 Hoare 分区法</h4>
<p>递归下的 <strong>Hoare</strong> 方法：</p>
<p>排序说明：</p>
<ol>
<li>基准值一般会选左端或右端值作为基准值，此处我们以左端为基准值</li>
<li>从后边开始找比基准值小的值停下来，与左侧所停值交换；</li>
<li>从前边开始找比基准值大的值停下来，与右侧所停值交换；</li>
<li>最终会停在中间某一处(将该处设为 <code>pivot</code>)，此处将数组分为左侧与右侧，采取分而治之的思想递归</li>
</ol>
<p><strong>不论好坏情况，一般声明时间复杂度 O(N logN)、空间复杂度 O(log N)</strong>
<img src="./images/Sort19.gif" alt="" /></p>
<p>注意以下顺序：</p>
<blockquote>
<ol>
<li>双指针从两端向中间移动</li>
<li>先移动右指针，再移动左指针</li>
<li>交换不满足条件的元素</li>
<li>最后基准归位</li>
</ol>
</blockquote>
<p>图中原数组顺序为：</p>
<p><strong>{6，1，2，7，9，3，4，5，10，8}</strong></p>
<p><strong>注意</strong>：由于基准值选在左端，必须<strong>先从右向左找小</strong>，再从左向右找大。如果左侧先移动，图中数组最终会在 9 位置停下，此时 9 不能与 6 交换；当左侧先走时<strong>能保证 left 会停在一个比 6 小的位置</strong>
<img src="./images/Sort20.webp" alt="" /></p>
<p>左右相同的程序进行排序，类似二叉树;</p>
<p><strong>注意</strong>内容已在图中说明，即<strong>循环结束判断标志</strong>如上。对照上面所描述步骤可以实现下面代码</p>
<pre><code>private static int partition (int[] array, int left, int right) {
    int tmp = array[left];
    int tmpLeft = left;
    // 双指针在数组中移动判断
    while (right &gt; left) {
        while (right &gt; left &amp;&amp; array[right] &gt;= tmp)
            right--;
        while (left &lt; right &amp;&amp; array[left] &lt;= tmp)
            left++;
        swap(array,right,left);
    }
    // 走完循环后，基准值还需与中间值交换
    swap(array,left,tmpLeft);
    return left;
}
</code></pre>
<h4>6.1.3 挖坑法（重点）</h4>
<h4><img src="./images/Sort21.gif" alt="" /></h4>
<p><strong>挖坑法</strong>描述：</p>
<ol>
<li>tmp 暂存左端值，方便后序放回数组</li>
<li>right 找比 tmp 小的值，若找到，则"填坑"到 left 位置，等待 right 交换完再移动；</li>
<li>left 找比 tmp 大的值，若找到，则"填坑"到 right 位置，然后等待 right 交换完再移动；</li>
<li>重复直到 left == right</li>
<li>最后将基准值填入相遇位置</li>
</ol>
<p><img src="./images/Sort22.webp" alt="" /></p>
<p>最终找到 pivot 中间值，该段方法仅需做到这点即可，剩余的是调用方法考虑的</p>
<pre><code>private static int hole_Partition (int[] array, int left, int right){
    int tmp = array[left];
    // 循环条件
    while (left &lt; right) {
        while (left &lt; right &amp;&amp; array[right] &gt;= tmp)
            right--;
        // 若右指针跳出循环停下，则右侧赋给左侧
        array[left] = array[right];

        while(left &lt; right &amp;&amp; array[left] &lt;= tmp)
            left++;
        // 若左指针跳出循环停下，则左侧赋给右侧
        array[right] = array[left];
    }
    // 最终相遇，将tmp拿回放中间
    array[left] = tmp;
    return left;
}
</code></pre>
<h4>6.1.4 双指针法</h4>
<p><strong>双指针法</strong>一般使用情况少，多数为<strong>挖坑法</strong></p>
<p><strong>双指针法</strong>描述：</p>
<ol>
<li><strong>tmp 暂存左端值</strong>，作为基准值，方便后续归位</li>
<li><strong>prev 指针初始指向 left</strong>，标记小于基准值的区域边界</li>
<li><strong>cur 指针从 left+1 开始向右遍历</strong>，寻找比基准值小的元素：
<ol>
<li>若 <code>array[cur] &lt; array[left]</code>，说明找到比基准小的值</li>
<li>先将 <code>prev++</code>，扩大至小于基准的区域</li>
<li>若<code>array[prev] != array[cur]</code>，则交换 <code>array[prev]</code>&lt;font style="color:rgb(15, 17, 21);"&gt; 与 &lt;/font&gt;<code>array[cur]</code>，将小值移到左边</li>
</ol>
</li>
<li><strong>重复步骤 3</strong>，知道 cur 指针遍历完整个区间</li>
<li><strong>最后将基准值填入 prev 位置</strong>，交换 <code>array[prev]</code>与 <code>array[left]</code>，返回 prev 作为基准最终位置</li>
</ol>
<p><strong>以下动图即为双指针法排序过程：</strong></p>
<p><img src="./images/Sort23.gif" alt="" /></p>
<p>代码实现如下：</p>
<pre><code>// 双指针法
private static int twop_Partition (int[] array, int left, int right){
    // 暂存基准值
    int tmp = array[left];
    int prev = left;
    int cur = prev + 1;

    while (cur &lt;= right) {
        // a. 若 array[cur] &lt; tmp，说明找到比基准小的值
        if (array[cur] &lt; tmp) {
            // b. prev++，扩大小于基准的区域
            prev++;
            // c. 若 array[prev] != array[cur]，则交换
            if (array[prev] != array[cur])
                swap(array,prev,cur);
        }
        cur++;
    }
    // 最后将基准值填入 prev 位置
    swap(array,left,prev);
    return prev;
}
</code></pre>
<h3>6.2 快速排序优化</h3>
<h4>6.2.1 三数取中优化</h4>
<p>三数取中的必要性</p>
<ol>
<li>三数取中是为了<strong>选择一个更好的基准值</strong>，以提高快速排序的效率。在快速排序中，选择一个合适的基准值是非常重要的，它决定了每次分割的平衡性。</li>
<li>如果每次选择的基准值都是最左边或最右边的元素，那么在某些情况下，快速排序的效率可能会降低。例如，<strong>当待排序序列已经有序时，如果每次选择的基准值都是最左边或最右边的元素，那么每次分割得到的两个子序列的长度差可能会非常大</strong>，导致递归深度增加，快速排序的效率降低。</li>
<li>而通过三数取中的优化，可以选择一个更好的基准值，使得每次分割得到的两个子序列的长度差更小，从而提高快速排序的效率；也就是<strong>尽量满足之前所说的全满二叉树</strong>，而不是单支二叉树</li>
</ol>
<p>使用三数取中法位置在调用子问题排序之前：</p>
<pre><code>void quickSort(int[] array, int left, int right) {
    // 递归终止条件：区间内没有元素或只有一个元素
    if (left &gt;= right) {
        return;
    }
    // 三数取中法调用出
    getMiddle(array,)
    
    // 分区操作：将数组分成两部分，并返回基准元素的最终位置
    int pivot = partition(array, left, right);
    // 递归排序左半部分：[left, pivot]
    quickSort(array, left, pivot);

    // 递归排序右半部分：[pivot+1, right]
    quickSort(array, pivot + 1, right);
}
</code></pre>
<p><img src="./images/Sort24.webp" alt="" /></p>
<p>根据上图思考以下比较代码：</p>
<pre><code>private static int getMiddle (int[] array, int left, int right) {
    // 找到中位置
    int mid = (left + right) / 2;
        
    if (array[left] &lt; array[right]) {
        if (array[mid] &lt; array[left])
            return left;
        else if (array[mid] &gt; array[right])
            return right;
        else
            return mid;
    } else {
        if (array[mid] &lt; array[right])
            return right;
        else if(array[mid] &gt; array[left])
            return left;
        else
            return mid;
    }
}
</code></pre>
<h4>6.2.2 小区间优化</h4>
<p>小区间优化是指在快速排序中，当待排序的子序列的长度小于一定阈值时，不再继续使用快速排序，而是转而使用直接插入排序。</p>
<p>代码实现：（以子序列元素个数为 10 举例）</p>
<p>仅修改在上面所述的递归框架的 quick 方法修改即可</p>
<pre><code>private static void quick(int[] array,int start,int end) {
    if (start &gt;= end) return;

    if(end - start + 1 &lt;= 10) {
        insertSortRange(array,start, end);
        return;
    }
    // 三数取中法调用出
    int midNum = getMiddle(array,start,end);
    // 更换基准值
    swap(array,start,midNum);

    int privot = twop_Partition(array,start,end);
    quick(array,start,privot - 1);
    quick(array,privot + 1,end);
}
private static void insertSortRange(int[] array,int start,int end) {
    for (int i = start + 1; i &lt;= end; i++) {
        // 定义临时变量进行存储、为下方提供比较对象
        int tmp = array[i];
        int j = i - 1;
        // 边找位置边移动
        while (j &gt;= start &amp;&amp; array[j] &gt; tmp) {
            // 将当前值后移
            array[j + 1] = array[j];
            j--;
        }
        // 插入到正确位置
        array[j + 1] = tmp;
    }
}
</code></pre>
<p><strong>要注意取值边界</strong></p>
<p>使用<strong>直接插入排序</strong>的原因：</p>
<ol>
<li>减少递归深度：使用插入排序来处理较小的子序列，可以减少递归的深度，从而减少了函数调用的开销。</li>
<li>直接插入排序对于本就较有序的数列排序更快，且一次走完数组就能够排序完成。</li>
</ol>
<h3>6.3 非递归快排</h3>
<p>非递归快排<strong>思路</strong>：</p>
<ol>
<li>创建栈，用来存储区间的<strong>左边界</strong>和<strong>右边界</strong>；将整个数组的左右边界压入栈</li>
<li>从栈中弹出一个区间的<strong>左右边界</strong>，堆该区间进行分区操作</li>
<li>选择<strong>基准值</strong>，将数组分为左右两部分，基准值归位，返回基准的最终值</li>
<li>将左子区间的左右边界压入栈，将右子区间的左右边界压入栈（重复步骤 2，知道栈空）</li>
</ol>
<p>以下为思路的实例举例：</p>
<p><img src="./images/Sort25.webp" alt="" /></p>
<p>代码实现如下：</p>
<pre><code>public static void quickNor(int[] array, int start, int end) {
    // 1. 创建栈，存储区间边界值
    Deque&lt;Integer&gt; stack = new ArrayDeque&lt;&gt;();
    
    // 2. 将初始区间边界压栈
    stack.push(start);  // 左边界
    stack.push(end);    // 右边界
    
    // 3. 循环处理，直到栈为空
    while (!stack.isEmpty()) {
        // 4. 弹出区间的边界值
        int right = stack.pop();  // 注意：先弹出右边界
        int left = stack.pop();   // 再弹出左边界
        
        // 5. 区间有效性检查
        if (left &gt;= right) {
            continue;
        }
        
        // 6. 分区操作，返回基准位置
        int pivot = partition(array, left, right);
        
        // 7. 将左子区间的边界值压栈
        if (left &lt; pivot - 1) {
            stack.push(left);      // 左边界
            stack.push(pivot - 1); // 右边界
        }
        
        // 8. 将右子区间的边界值压栈
        if (pivot + 1 &lt; right) {
            stack.push(pivot + 1); // 左边界
            stack.push(right);     // 右边界
        }
    }
}
</code></pre>
<p><strong>注意</strong>：pivot 的左右侧为 1 个元素时，不进行压栈</p>
<h2>7. 归并排序</h2>
<p><strong>归并排序</strong>（Merge Sort）采用<strong>分治思想</strong>。其核心思路是：先把数组不断分成两半，</p>
<p>直到每部分只有一个元素，然后再将有序的两部分合并成更大的有序部分，最终整个数组有序；</p>
<p>归并排序的过程：</p>
<ol>
<li>先判断<strong>当前数组长度</strong>是否≤1，如果是则直接返回（递归出口）；</li>
<li>若长度大于 1，则找到数组中间位置，将数组分成<strong>左半部分</strong>和<strong>右半部分</strong>；</li>
<li>对左半部分<strong>递归调用并排序</strong>（重复 1~3 步，直到左半部分为单独个体）</li>
<li>对右半部分<strong>递归调用并排序</strong>（重复 1~3 步，直到右半部分为单独个体）</li>
<li>左右两部分都已有序后，<strong>新建一个临时数组</strong>；</li>
<li><strong>同时遍历左右两部分</strong>：比较两个部分的当前元素，将较小的那个放入临时数组，并移动对应部分的指针；</li>
<li>重复第 6 步，直到某一部分的所有元素都已被放入临时数组；</li>
<li>将另一部分剩余的所有元素依次放入临时数组；</li>
<li>将临时数组中的元素复制回原数组对应的位置；</li>
<li>返回上一层的递归，继续合并更大的有序片段，直到整个数组完全有序；</li>
</ol>
<p><strong>图形化表示</strong>如下：</p>
<p><img src="./images/Sort26.webp" alt="" /></p>
<h3>7.1 归并排序实例</h3>
<p><strong>具体动图如下</strong>：</p>
<p>在实际图像表示中，是像动图中，整体划分好两大部分，每部分中两个为一组；</p>
<p>两个为一组排好两组后，以四个为一组再调整；</p>
<p>四个为一组排好两组后，以八个为一组再调整...</p>
<p>最终两大组再合并一起；</p>
<p><img src="./images/mergeSort.gif" alt="" /></p>
<p><strong>实例参考</strong>：</p>
<p>原始数组为 <strong>[10, 6, 7, 1, 3, 9, 4, 2]</strong></p>
<ol>
<li><strong>递归拆分</strong></li>
</ol>
<p>第一层：[0,7] → [0,3] + [4,7]</p>
<p>第二层： [0,3] → [0,1] + [2,3]<br />
[4,7] → [4,5] + [6,7]</p>
<p>第三层：（拆到为单项）</p>
<p>[0,1] → [0] [1]</p>
<p>[2,3] → [2] [3]</p>
<p>[4,5] → [4] [5]</p>
<p>[6,7] → [6] [7]</p>
<p>此时数组被划分为：<strong>[10] [6] [7] [1] [3] [9] [4] [2]</strong></p>
<ol>
<li><strong>从下往上合并</strong></li>
</ol>
<p><code>merge(array, 0, 0, 1)</code>，合并 [0,1] → 【10 和 6】</p>
<p><code>merge(array, 2, 2, 3)</code>，合并 [2,3] → 【7 和 1】</p>
<p><code>merge(array, 4, 4, 5)</code>， 合并 [4,5] → 【3 和 9】</p>
<p><code>merge(array, 6, 6, 7)</code>， 合并 [6,7] → 【4 和 2】</p>
<ol>
<li><strong>继续往上合并</strong></li>
</ol>
<p>第二轮合并：[6,10] + [1,7] → [1,6,7,10]</p>
<p>[3,9] + [2,4] → [2,3,4,9]</p>
<p>第三轮合并：[1,6,7,10] + [2,3,4,9] → [1,2,3,4,6,7,9,10]</p>
<hr />
<h4>7.2 递归实现</h4>
<p><strong>具体代码实现</strong>：</p>
<pre><code>public static void mergeSort(int[] array) {
    mergeSortTmp(array,0,array.length - 1);
}

private static void mergeSortTmp(int[] array,int left,int right) {
    if (left &gt;= right) return;

    // 1.递归分组
    int mid = (left + right) / 2;
    mergeSortTmp(array, left, mid);
    mergeSortTmp(array, mid + 1, right);

    // 2.合并
    merge(array,left,mid,right);
}

private static void merge(int[] array,int left,
                          int mid,int right) {
    // 1.创建临时数组
    int[] tmp = new int[right - left + 1];
    int k = 0;  // 记录临时数组索引
    int s1 = left;      // 左侧小组起始位
    int s2 = mid + 1;   // 右侧小组起始位

    // 2.循环比较放入
    while (s1 &lt;= mid &amp;&amp; s2 &lt;= right) {
        if (array[s1] &lt;= array[s2])
            tmp[k++] = array[s1++];
        else
            tmp[k++] = array[s2++];
    }
    // 3.将s1或s2剩下的填入
    while (s1 &lt;= mid)
        tmp[k++] = array[s1++];
    while (s2 &lt;= right)
        tmp[k++] = array[s2++];

    // 4.将临时数组拷贝入原始数组中
    for (int i = 0; i &lt; k; i++) {
        array[i + left] = tmp[i];
    }
}
</code></pre>
<h4>7.3 非递归实现</h4>
<p><strong>实例参考</strong>：</p>
<p><img src="./images/Sort27.webp" alt="" /></p>
<pre><code>// 归并排序 非递归实现
public static void mergeSortNor (int[] array) {
    int gap = 1;    // 小组之间的间隔

    while (gap &lt; array.length) {
        for (int i = 0; i &lt; array.length; i = i + 2 * gap) {
            int left = i;   // 记录第一个小组左值下标
            int mid = left + gap - 1;   // 记录第一个小组右值下标
            // 防止mid数组越界
            if (mid &gt;= array.length)
                mid = array.length - 1;

            int right = mid + gap;  // 记录第二个小组右值下标
            // 防止right数组越界
            if(right &gt;= array.length)
                right = array.length - 1;
            merge(array,left,mid,right);
        }
        gap *= 2;
    }
}

private static void merge (int[] array,int left,
                              int mid,int right) {
    // 1.创建临时数组
    int[] tmp = new int[right - left + 1];
    int k = 0;  // 记录临时数组索引
    int s1 = left;      // 左侧小组起始位
    int s2 = mid + 1;   // 右侧小组起始位

    // 2.循环比较放入
    while (s1 &lt;= mid &amp;&amp; s2 &lt;= right) {
        if (array[s1] &lt;= array[s2])
            tmp[k++] = array[s1++];
        else
            tmp[k++] = array[s2++];
    }
    // 3.将s1或s2剩下的填入
    while (s1 &lt;= mid)
        tmp[k++] = array[s1++];
    while (s2 &lt;= right)
        tmp[k++] = array[s2++];

    // 4.将临时数组拷贝入原始数组中
    for (int i = 0; i &lt; k; i++) {
        array[i + left] = tmp[i];
    }
}
</code></pre>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据结构与算法"/>
  </entry>
  <entry>
    <title>二叉树：结构性质、存储方式与遍历实现</title>
    <link href="https://blog.syrena.cn/posts/binarytree/binarytree/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/binarytree/binarytree/</id>
    <published>2026-04-08T00:00:00.000Z</published>
    <updated>2026-04-08T00:00:00.000Z</updated>
    <summary>从树的基础概念出发，系统总结二叉树的结构特点、性质推导、存储方式以及多种遍历实现方法.</summary>
    <content type="html"><![CDATA[<h2>1. 树型结构</h2>
<h3>1.1 了解树</h3>
<p><img src="./BinaryTree1.webp" alt="" /></p>
<p>形如图中的结构，称之为树型结构。实际上，<code>数据结构的树 = 图论中的树 + 根节点 + 父子关系</code></p>
<p>对于数据结构中树的定义</p>
<blockquote>
<p>树是由 n（n &gt;= 0）个节点但组成的有限集合。</p>
</blockquote>
<p>其中，树的一些基本概念有以下：</p>
<blockquote>
<p><strong>节点的度</strong>：一个节点含有子树的个数称为该节点的度；</p>
<p><strong>树的度</strong>：一棵树中，所有节点度的最大值称为树的度；上图树的度为 3</p>
<p><strong>叶子节点或终端节点</strong>：度为 0 的节点称为叶子节点；</p>
<p><strong>双亲节点或父节点</strong>：若一个节点含有子节点，则这个节点称为其子节点的父节点；</p>
<p><strong>根节点</strong>：一棵树中，没有双亲节点的节点；</p>
<p><strong>树的高度或深度</strong>：树中节点的最大层次；上图树的高度为 4</p>
<p><strong>非终端节点或分支节点</strong>：度不为 0 的节点；</p>
</blockquote>
<h2>2.二叉树</h2>
<h3>2.1 二叉树的概念</h3>
<p>二叉树是一个有限节点集合，满足：</p>
<blockquote>
<ol>
<li>可以是空集（空二叉树）</li>
<li>如果不为空，则由三部分组成：根节点（Root）、左子树（Left Subtree）、右子树（Right Subtree)</li>
</ol>
</blockquote>
<p>并且：左子树和右子树本身也是二叉树</p>
<h4>2.1.1 特殊的两种二叉树</h4>
<ol>
<li><strong>满二叉树</strong>：一棵二叉树，若每层的节点树都达到最大值，则这棵二叉树就是满二叉树。从数学关系上看，一颗二叉树的层数为 K，则节点总数为 2&lt;sup&gt;k&lt;/sup&gt;- 1，则它就是满二叉树。</li>
<li><strong>完全二叉树</strong>：除了最后一层外，其余各层都被完全填满，并且最后一层的节点从左至右连续排列，中间不能空缺</li>
</ol>
<p>图像概念如下：
<img src="./BinaryTree2.webp" alt="" /></p>
<h3>2.2 二叉树的性质</h3>
<ol>
<li>若规定<strong>根结点的层数为1</strong>，则一棵<strong>非空二叉树的第i层上最多有</strong> 2&lt;sup&gt;i-1&lt;/sup&gt;<strong>(i&gt;0)个结点</strong></li>
<li>若规定只有<strong>根结点的二叉树的深度为1</strong>，则<strong>深度为K</strong>的二叉树的最大结点数是 2&lt;sup&gt;k&lt;/sup&gt; - 1(k&gt;=0)</li>
<li>对任何一棵二叉树, 如果其<strong>叶结点个数为</strong> n&lt;sub&gt;0&lt;/sub&gt;, <strong>度为2</strong>的<strong>非叶结点个数为</strong> n&lt;sub&gt;2&lt;/sub&gt;,则<strong>有n</strong>&lt;sub&gt;<strong>0</strong>&lt;/sub&gt;<strong>＝n</strong>&lt;sub&gt;<strong>2</strong>&lt;/sub&gt;<strong>＋1</strong></li>
<li>具<strong>有n个结点</strong>的<strong>完全二叉树</strong>的<strong>深度k</strong>为 log&lt;sub&gt;2&lt;/sub&gt;(<strong>n+1</strong>) <strong>上取整</strong></li>
<li>对于具有<strong>n个结点的完全二叉树</strong>，如果按照<strong>从上至下从左至右</strong>的顺序对所有节点从0开始编号，则对于序号为 i 的节点有：
<ul>
<li>若 i &gt; 0,双亲序号：(i - 1)/2; i = 0,<strong>i 为根节点编号</strong>，无双亲节点</li>
<li><strong>若2i+1&lt;n，左孩子序号：2i+1，否则无左孩子</strong></li>
<li><strong>若2i+2&lt;n，右孩子序号：2i+2，否则无右孩子</strong></li>
</ul>
</li>
</ol>
<hr />
<p><strong>例题</strong>：</p>
<p><strong>已知</strong>：总共节点个数为 n 的完全二叉树，求叶子节点个数？</p>
<p><strong>前提说明</strong>：<strong>公式</strong><code>n₀ = n₂ + 1</code>、<code>n₀ + n₁ + n₂ = n</code></p>
<p>我们做<strong>分类讨论</strong></p>
<p><strong>情况一： n 为偶数</strong></p>
<p>完全二叉树中必有且仅有一个度为 1 的节点，即：  <code>n₁ = 1</code></p>
<p>代入性质，即<code>n₀ + 1 + n₂ = n</code></p>
<p>结合<code>n₀ = n₂ + 1</code>：  可得<code>n₂ = (n - 2) / 2</code></p>
<p>综上， <code>n₀ = n₂ + 1 = n / 2</code></p>
<hr />
<p><strong>情况二：n 为奇数</strong></p>
<p>完全二叉树中不存在度为 1 的节点，即：  <code>n₁ = 0</code></p>
<p>代入性质，即<code>n₀ + n₂ = n</code></p>
<p>结合<code>n₀ = n₂ + 1</code>：  可得<code>n₂ = (n - 1) / 2</code></p>
<p>综上，<code>n₀ = n₂ + 1 = (n + 1) / 2</code></p>
<hr />
<p><strong>总结该过程就是以下公式：</strong><code>n₀ = ⌈n / 2⌉</code><strong>注意是向上取整</strong></p>
<h4>2.2.1 性质推导</h4>
<p>对于性质 3，我们给出以下推导</p>
<blockquote>
<p>已知：一棵 N 个节点的树有 N-1 条边，下标 1、2 分别代表度为 1，2 的节点能产生的边数：</p>
<p>n&lt;sub&gt;1&lt;/sub&gt;:产生 n&lt;sub&gt;1&lt;/sub&gt;-1 条边</p>
<p>n&lt;sub&gt;2&lt;/sub&gt;:产生 2*n&lt;sub&gt;2&lt;/sub&gt; 条边</p>
<p>n&lt;sub&gt;1&lt;/sub&gt;+ 2*n&lt;sub&gt;2&lt;/sub&gt; = N - 1	方程式 1</p>
<p>n&lt;sub&gt;0&lt;/sub&gt; + n&lt;sub&gt;1&lt;/sub&gt;+ n&lt;sub&gt;2&lt;/sub&gt; = N	方程式 2</p>
<p>联立解得：<strong>n&lt;sub&gt;2&lt;/sub&gt; + 1 = n&lt;sub&gt;0&lt;/sub&gt;</strong></p>
</blockquote>
<h3>2.3 二叉树的存储</h3>
<p><strong>二叉树的存储结构</strong>分为：<strong>顺序存储</strong>和<strong>链式存储</strong>；</p>
<h4>2.3.1 二叉树的链式存储</h4>
<p>二叉树的链式存储是<strong>通过一个个的节点</strong>引用起来的，常见的表示方式有<strong>二叉链表、三叉链表、线索二叉树</strong>表示方法；</p>
<p>链式存储使用情况：</p>
<ol>
<li>形状不规则的普通二叉树</li>
<li>动态变化的二叉树</li>
<li>需要实现复杂逻辑的树</li>
</ol>
<pre><code>//二叉链表
class Node {
    int val; // 数据域
    Node left; // 左孩子的引用，常常代表左孩子为根的整棵左子树
    Node right; // 右孩子的引用，常常代表右孩子为根的整棵右子树
}
// 三叉链表
class Node {
    int val; // 数据域
    Node left; // 左孩子的引用，常常代表左孩子为根的整棵左子树
    Node right; // 右孩子的引用，常常代表右孩子为根的整棵右子树
    Node parent; // 当前节点的根节点
}
// 线索二叉树
class Node {
    int val;
    ThreadedNode left;
    ThreadedNode right;
    
    // 标志位：0代表孩子，1代表线索（指向前驱/后继）
    int lTag; 
    int rTag; 
}
</code></pre>
<p><strong>线索二叉树</strong>：</p>
<p>在普通的二叉链表中，一个拥有 n 个结点的二叉树会有 n+1 个空指针域。<strong>线索二叉树</strong>的核心思想就是利用这些空指针来存放结点在某种遍历序列中的“前驱”和“后继”信息，从而加快遍历速度.</p>
<p>实质就是：线索二叉树 = 用空指针存遍历顺序</p>
<p>线索树中的 right 可以类似理解为<strong>链表中的 next <strong>可以</strong>向上走</strong>，也可以像普通二叉树一样<strong>往下找右子树走</strong></p>
<pre><code>ltag = 0 → 左孩子
ltag = 1 → 前驱（线索）

rtag = 0 → 右孩子
rtag = 1 → 后继（线索）
</code></pre>
<p>如中序遍历不用递归（线索树）</p>
<pre><code>public void inorderTraverse(ThreadNode root) {
    ThreadNode cur = root;

    // 找到最左节点
    while (cur.ltag == 0) {
        cur = cur.left;
    }

    while (cur != null) {
        System.out.print(cur.val + " ");

        // 如果是线索 → 直接走后继
        if (cur.rtag == 1) {
            cur = cur.right;
        } else {
            // 否则去右子树找最左
            cur = cur.right;
            while (cur != null &amp;&amp; cur.ltag == 0) {
                cur = cur.left;
            }
        }
    }
}
</code></pre>
<p>而将二叉树转为线索树需要用线索化方法将其转换，如下</p>
<pre><code>public class ThreadedBinaryTreeDemo {

// 节点定义
static class ThreadNode {
    char val;
    ThreadNode left;
    ThreadNode right;

    int ltag; // 0 = 左孩子，1 = 前驱
    int rtag; // 0 = 右孩子，1 = 后继

    public ThreadNode(char val) {
        this.val = val;
    }
}

// 全局前驱指针
static ThreadNode pre = null;
// 递归过程中需要共享“上一个访问的节点”

// 中序线索化
public static void inorderThread(ThreadNode node) {
    if (node == null) return;

    // 1. 处理左子树（保证顺序：左→根→右）
    inorderThread(node.left);

    // 2. 处理当前节点

    // (1) 建立前驱线索
    if (node.left == null) {
        node.left = pre;
        node.ltag = 1;
    }

    // (2) 建立后继线索
    if (pre != null &amp;&amp; pre.right == null) {
        pre.right = node;
        pre.rtag = 1;
    }

    // (3) 更新 pre（当前节点成为下一个节点的前驱）
    pre = node;

    // 3. 处理右子树
    inorderThread(node.right);
}
</code></pre>
<h4>2.3.2 二叉树的顺序存储</h4>
<p>顺序存储适合情况：</p>
<ol>
<li>完全二叉树</li>
<li>满二叉树</li>
</ol>
<p>顺序存储的核心是：<strong>使用数组来模拟二叉树的结构</strong></p>
<p>就如前面性质所提到的：</p>
<p><strong>左孩子：2 * i + 1</strong></p>
<p><strong>右孩子：2 * i + 2</strong>
<img src="./BinaryTree3.webp" alt="" /></p>
<p>顺序存储（数组下标） = 二叉树的层序遍历顺序</p>
<blockquote>
<p>注：当且仅当是完全二叉树时</p>
</blockquote>
<p>当不是完全二叉树时，其存储情况如此图
<img src="./BinaryTree4.webp" alt="" /></p>
<h3>2.4 二叉树的基本操作</h3>
<h4>2.4.1 二叉树的三种遍历</h4>
<h5>1.前序遍历</h5>
<pre><code>    // 前序遍历
    public void preorderTree(TreeNode root) {
        if(root == null) return;
        System.out.print(root.val+" ");
        preorderTree(root.left);
        preorderTree(root.right);
    }
</code></pre>
<p><strong>非递归方法如下</strong>：
<img src="./BinaryTree5.webp" alt="" /></p>
<p><strong>思路</strong></p>
<blockquote>
<p>通过栈来存储当前节点的上一位</p>
</blockquote>
<p><strong>解题过程</strong></p>
<blockquote>
<ol>
<li>将 root 赋给 cur，进入循环</li>
<li>循环中 cur 拿到节点就进行压栈并打印，随后向左移动</li>
<li>向左时一路压栈一路打印，直至 cur == null 时，再弹出前一个节点，将其<strong>右索引</strong>赋值给 cur 并将其进行循环（将 cur 视为另一子树的根节点即可理解）</li>
</ol>
</blockquote>
<pre><code>    // 前序遍历非递归
    public void preorderNor(TreeNode root) {
        if(root == null) return;
        Stack&lt;TreeNode&gt; stack = new Stack&lt;&gt;();
        TreeNode cur = root;

        // 循环判断cur的右侧是否有元素
        while(cur != null || !stack.isEmpty()) {
            // 循环往下遍历左侧元素
            while(cur != null) {
                stack.push(cur);
                System.out.print(cur.val + " ");
                cur = cur.left;
            }
            // cur为空，需要提出栈中一个元素
            TreeNode top = stack.pop();
            cur = top.right;
        }
    }
</code></pre>
<p><strong>注意</strong>：中序遍历（非递归）与此一样，只是打印位置不同</p>
<p><strong>复杂度</strong></p>
<blockquote>
<p>时间复杂度：O(N)</p>
<p>空间复杂度：<strong>O(N)</strong>（最坏）/ <strong>O(log N)</strong>（平均）/ <strong>O(1)</strong>（右单支树）</p>
</blockquote>
<h5>2.中序遍历</h5>
<pre><code>// 中序遍历
public void inorderTree(TreeNode root) {
    if(root == null) return;
    inorderTree(root.left);
    System.out.print(root.val+" ");
    inorderTree(root.right);
}
</code></pre>
<p><strong>中序遍历非递归方法与前序遍历非递归类似，仅仅调换打印位置</strong></p>
<pre><code>// 中序遍历非递归
public void inorderNor(TreeNode root) {
    if(root == null) return;
    Stack&lt;TreeNode&gt; stack = new Stack&lt;&gt;();
    TreeNode cur = root;

    // 循环判断cur的右侧是否有元素
    while(cur != null || !stack.isEmpty()) {
        // 循环往下遍历左侧元素
        while(cur != null) {
            stack.push(cur);
            cur = cur.left;
        }
        // cur为空，需要提出栈中一个元素
        TreeNode top = stack.pop();
        System.out.print(top.val + " ");
        cur = top.right;
    }
}
</code></pre>
<h5>3.后序遍历</h5>
<pre><code>// 后序遍历
public void postorderTree(TreeNode root) {
    if(root == null) return;
    postorderTree(root.left);
    postorderTree(root.right);
    System.out.print(root.val+" ");
}
</code></pre>
<p><strong>下面为非递归方法：</strong>
<img src="./BinaryTree6.webp" alt="" /></p>
<p><strong>注意：</strong></p>
<p>如上图，当 cur 走到 7 时，假设还存在右值“10”，直至走完 “10”，随后弹出 7，又会再次走到判断 7 是否存在右值，因此进入死循环。</p>
<p>为此，我们在<strong>循环外</strong>定义一个节点<code>prev</code>，以此来标记我们打印过的节点以<strong>避免死循环</strong>。加上<code>if(top.right != null &amp;&amp; top.right != prev)</code>这条判断语句即可</p>
<p><strong>思路</strong></p>
<blockquote>
<p>通过栈来存储当前节点的上一位</p>
</blockquote>
<p><strong>解题过程</strong></p>
<blockquote>
<ol>
<li>思路延续前面的前序以及中序遍历的思想，仅在处理打印顺序时候需调整</li>
</ol>
</blockquote>
<pre><code>    // 后序遍历非递归
    public void postorderNor(TreeNode root) {
        if(root == null) return;

        Stack&lt;TreeNode&gt; stack = new Stack&lt;&gt;();
        TreeNode cur = root;
        TreeNode prev = null;
        while(cur != null || !stack.isEmpty()) {
            while(cur != null) {
                stack.push(cur);
                cur = cur.left;
            }
            TreeNode top = stack.peek();
            // 注意此处prev用来标记前一个元素是否有被打印
            if(top.right != null &amp;&amp; top.right != prev) {
                cur = top.right;
            } else {
                System.out.print(top.val + " ");
                stack.pop();
                prev = top;
            }
        }
    }
</code></pre>
<p><strong>复杂度</strong></p>
<blockquote>
<p><strong>时间复杂度：O(N)</strong></p>
<p><strong>空间复杂度</strong>： <strong>O(N)</strong>（最坏） / <strong>O(log N)</strong>（最好）</p>
</blockquote>
<h4>2.4.2 二叉树的其他操作</h4>
<h5>1. 获取树中节点个数</h5>
<pre><code>// 左子树叶子个数 + 右子树叶子个数（子问题法）
public int getLeafNodeCount(TreeNode root){
    if(root == null) return 0;
    if(root.left == null &amp;&amp; root.right == null) return 1;
    return getLeafNodeCount(root.left) + getLeafNodeCount(root.right);
}
</code></pre>
<h5>2. 获取叶子节点个数</h5>
<pre><code>// 获取叶子个数（遍历法）
public static int count = 0;
public void getLeafNodeCount(TreeNode root) {
    if(root == null) return;
    if(root.left == null &amp;&amp; root.right == null) {
        count++;
    }
    getLeafNodeCount(root.left);
    getLeafNodeCount(root.right);
}
</code></pre>
<h5>3. 获取二叉树的高度</h5>
<pre><code>public int getHeight(TreeNode root) {
    if(root == null) return 0;
    return Math.max(getHeight(root.right)
            , getHeight(root.left)) + 1;
}
</code></pre>
<h5>4. 层序遍历</h5>
<pre><code>public void levelOrder(TreeNode root) {
    if (root == null) return;
    Queue&lt;TreeNode&gt; queue = new LinkedList&lt;&gt;();
    queue.offer(root);
    
    while (!queue.isEmpty()) {
        TreeNode cur = queue.poll();
        System.out.print(cur.val + " ");
        
        if (cur.left != null) queue.offer(cur.left);
        if (cur.right != null) queue.offer(cur.right);
    }
}
</code></pre>
<h3>二叉树 Oj 练习</h3>
<p>此题是转换为循环链表</p>
<p><a href="https://leetcode.cn/problems/er-cha-sou-suo-shu-yu-shuang-xiang-lian-biao-lcof/">LCR 155. 将二叉搜索树转化为排序的双向链表 - 力扣（LeetCode）</a></p>
<p><a href="https://leetcode.cn/problems/binary-tree-preorder-traversal/description/">144. 二叉树的前序遍历 - 力扣（LeetCode）</a></p>
<p><a href="https://www.nowcoder.com/practice/947f6eb80d944a84850b0538bf0ec3a5?tpId=13&amp;tqId=11179&amp;ru=/exam/oj">二叉搜索树与双向链表_牛客题霸_牛客网</a></p>
<p><a href="https://leetcode.cn/problems/binary-tree-level-order-traversal/description/">102. 二叉树的层序遍历 - 力扣（LeetCode）</a></p>
<p><a href="https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/">236. 二叉树的最近公共祖先 - 力扣（LeetCode）</a></p>
<p>以上题解可参考另一篇文章~</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据结构与算法"/>
  </entry>
  <entry>
    <title>堆与优先队列：建堆、调整与 Top K 问题</title>
    <link href="https://blog.syrena.cn/posts/heap/heap/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/heap/heap/</id>
    <published>2026-04-08T00:00:00.000Z</published>
    <updated>2026-04-08T00:00:00.000Z</updated>
    <summary>从堆的结构与操作出发，讲解建堆、调整及优先队列实现，并给出 TopK 问题的常见解法.</summary>
    <content type="html"><![CDATA[<h2>1.堆的概念</h2>
<p>如果有一个<strong>关键码的集合K = {k0，k1， k2，…，kn-1}</strong>，把它的所有元素<strong>按完全二叉树的顺序</strong>存储方式存储<strong>在一个一维数组</strong>中，并满足：<strong>K&lt;sub&gt;i&lt;/sub&gt; &lt;= K&lt;sub&gt;2i+1&lt;/sub&gt; 且 K&lt;sub&gt;i&lt;/sub&gt;&lt;= K&lt;sub&gt;2i+2&lt;/sub&gt;</strong> (K&lt;sub&gt;i&lt;/sub&gt; &gt;= K&lt;sub&gt;2i+1&lt;/sub&gt; 且 K&lt;sub&gt;i&lt;/sub&gt; &gt;= K&lt;sub&gt;2i+2&lt;/sub&gt;) i = 0，1，2…，则<strong>称为 小堆</strong>(或大堆)。将根节点最大的堆叫做<strong>最大堆</strong>或<strong>大根堆</strong>，根节点最小的堆叫做<strong>最小堆</strong>或<strong>小根堆</strong>。</p>
<p><strong>堆</strong>的性质:</p>
<ol>
<li>堆中某个节点的值总是不大于或不小于其父节点的值;</li>
<li>堆总是一棵完全二叉树;</li>
</ol>
<h2>2. 堆的存储方式</h2>
<p>根据堆的概念可知,堆是一棵完全二叉树,因此<strong>可以按照层序规则采用顺序存储结构（数组）来高效存储</strong>。如上图上面部分的数组;</p>
<p><strong>注意</strong>: 对于<strong>非完全二叉树</strong>,则<strong>不适用顺序方式进行存储</strong>因为为了能够还原二叉树,空间中就必要存储空节点,因此导致空间利用率底.</p>
<p>给部分内容设计二叉树部分性质,如下:</p>
<blockquote>
<p>如果i为0，则i表示的节点为根节点，否则i节点的双亲节点为 (i - 1)/2</p>
<p>如果2 * i + 1 小于节点个数，则节点i的左孩子下标为2 * i + 1，否则没有左孩子</p>
<p>如果2 * i + 2 小于节点个数，则节点i的右孩子下标为2 * i + 2，否则没有右孩子</p>
</blockquote>
<h2>3. 堆的创建</h2>
<h3>3.1 堆的向下调整(大根堆/小根堆)</h3>
<p>向下调整适用于 <strong>1. 建堆</strong>、<strong>2.删除堆顶元素</strong>、<strong>3.修改某个节点的值（变小时</strong>）</p>
<p>例:集合{ 27,15,19,18,28,34,65,49,25,37 }<strong>调整为大根堆</strong></p>
<p>过程如下:
<img src="./Heap2.webp" alt="" /></p>
<p>我们要从<strong>最后一个非叶子节点</strong>下手,因此我们选定 4 下标与其子树进行比较,因此 4 下标为 parent 索引,9 下标则为 child 索引</p>
<blockquote>
<ol>
<li>child &gt; parent 则交换,child = parent 赋值,parent = (parent -1)/2 向上走</li>
<li>此刻 child &gt; parent,重复步骤一随后得到 parent 为 0 索引</li>
</ol>
</blockquote>
<p>以上<strong>为一轮 for 循环</strong>,因此我们只需要<strong>对非叶子节点从后往前</strong>一步步循环换上去即可</p>
<p>代码实现如下:</p>
<pre><code>/**
 * parent:usedSize-1为最后一位元素
 * (usedSize-2)/2 为求父节点公式
 */
// O(n)
public void createHeap() {
    for (int parent = (this.usedSize-2)/2; parent &gt;= 0; parent--) {
        siftDown(parent,this.usedSize);    // parent逐步-1 根节点往上走
    }
}
/**
 * @param parent 每棵子树调整的时候 的 起始位置
 * @param usedSize 判断 每棵子树什么时候 调整 结束
 */
// 大根堆 -- 每棵树的根节点向下调
private void siftDown(int parent,int usedSize) {
    int child = 2 * parent + 1; // 求得左子节点所在位
    while(child &lt; usedSize) {   //
        if(child + 1 &lt; usedSize &amp;&amp; elem[child] &lt; elem[child + 1]) {
            child++;    // 若右子树存在且小于左子树，则找到右子树
        }
        if(elem[child] &gt; elem[parent]) {    // 如果于它的根，则换
            swap(elem,child, parent);
            parent = child;     // 根节点向下移动，循环将下面的排好
            child = 2 * parent + 1; // 找根节点下面的左子树
        } else {
            break;  // 跳出循环
        }
    }
}
private void swap(int[] elem,int i, int j) {
    int tmp = elem[i];
    elem[i] = elem[j];
    elem[j] = tmp;
}
</code></pre>
<p>如若想改为调整为小根堆,只需要将代码中的 <strong>"&lt;"和"&gt;"互换一下</strong> 即可,以上方法称为向下调整法</p>
<h3>3.2 堆的向上调整(小根堆/大根堆)</h3>
<p>向上调整常用于 <strong>1.插入新元素(放在数组末尾)</strong>、<strong>2.减小某个节点的值</strong>、<strong>3.删除节点后的调整</strong></p>
<p>以插入新元素为例：
<img src="./Heap3.webp" alt="" /></p>
<p>过程如下：</p>
<blockquote>
<ol>
<li>child 为插入元素索引，利用公式求得 parent 位置进行比较，若 child &gt; parent；反之</li>
<li>child 与 parent 交替向上走</li>
</ol>
</blockquote>
<pre><code>// 传入元素
public void offer(int val) {
    if(isFull()) {
        elem = Arrays.copyOf(elem, 2*elem.length);
    }
    elem[usedSize] = val;
    siftUp(usedSize);
    usedSize++;
}
private boolean isFull() {
    return elem.length == usedSize;
}
public void siftUp(int child) {
    int parent = (child - 1)/2;
    while(child &gt; 0) {  // 当 child 不是根节点时继续
        if(elem[child] &gt; elem[parent]) {
            swap(elem,child,parent);
            child = parent;
            parent = (parent-1)/2;
        } else {
            break;
        }
    }
}
</code></pre>
<h2>topK 问题</h2>
<p>topK 问题：有 N 个元素，找出前 K 个最小元素</p>
<p>做法：</p>
<blockquote>
<ol>
<li>方法一：整体排序</li>
<li>方法二：整体建立一个大小为 N 的小根堆</li>
<li>方法三：把前 K 个元素创建为大根堆，遍历剩下的 N-K 个元素，和堆顶元素比较，如果比堆顶元素小，则堆顶元素删除，当前元素入堆</li>
</ol>
</blockquote>
<p>在此，我们以最优最常用的方法三来分析：
<img src="./Heap4.webp" alt="" /></p>
<p>思路如图中所述，因为只要<strong>数组元素小于前 k 个的最大值</strong>，一次次<strong>将每一次的最大值挤出</strong>该而队列中，去<strong>插入更小的元素</strong>，此处我们调用比较器去重写 compare 方法</p>
<p><strong>注意</strong>：</p>
<blockquote>
<p>优先级队列背后的 compare 默认是调整为小根堆，因此我们需要将其重写，调整为大根堆</p>
</blockquote>
<pre><code>// 创建比较器
class IntCmp implements Comparator&lt;Integer&gt; {
    @Override
    public int compare(Integer o1, Integer o2) {
        // 当 o2 &lt; o1 时，o1 会被放在堆顶方向
        // 修改默认小根堆改为大根堆
        return o2.compareTo(o1);
    }
}

// 与大根堆比较
public static int[] smallestK(int[] arr,int k) {
    // 创建返回数组
    int[] ret = new int[k];
    if(arr == null || k == 0) return ret;
    PriorityQueue&lt;Integer&gt; priorityQueue = new PriorityQueue&lt;&gt;(k,new IntCmp());
    // 取出k个元素建立二叉树
    for (int i = 0; i &lt; k; i++) {
        priorityQueue.offer(arr[i]);
    }
    // 遍历N - K个元素比较
    for (int i = k; i &lt; arr.length; i++) {
        // 如果小于堆顶元素，则放入元素，删除堆顶元素
        int peekValue = priorityQueue.peek();
        if(arr[i] &lt; peekValue) {
            priorityQueue.poll();
            priorityQueue.offer(arr[i]);
        }
    }
    // 取出前K个元素
    for (int i = 0; i &lt; k; i++) {
        ret[i] = priorityQueue.poll();
    }
    return ret;
}
</code></pre>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据结构与算法"/>
  </entry>
  <entry>
    <title>顺序表与链表：ArrayList、LinkedList 的原理与实现</title>
    <link href="https://blog.syrena.cn/posts/linkedlist/linkedlist/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/linkedlist/linkedlist/</id>
    <published>2026-03-27T00:00:00.000Z</published>
    <updated>2026-03-27T00:00:00.000Z</updated>
    <summary>结合 Java 源码与图解，通俗理解顺序表（ArrayList）与双向链表（LinkedList）的底层原理，并附带单链表常用核心操作的模拟实现.</summary>
    <content type="html"><![CDATA[<p>图片来自于<a href="https://www.runoob.com/java/java-collections.html">菜鸟教程</a></p>
<h2>1.List 引入</h2>
<h3>1.什么是 List</h3>
<p>在了解 <code>ArrayList</code>即顺序表 (<code>Sequential List</code>) 与 <code>LinkedList</code>即 双向链表 (<code>Doubly Linked List</code>) 之前，有必要先明 <code>List</code>是什么。</p>
<p><code>List</code> 是一个接口，其继承自 <code>Collection</code>，类似可以理解为“合同”。它规定了所有子类需要具备的功能：索引访问 <code>get(i)</code>、插入元素 <code>add</code>、删除元素 <code>remove</code> 等功能。</p>
<p><strong>List 与 ArrayList、LinkedList 的关系</strong></p>
<p>List 首先是一个接口（可理解为合同），而 <code>ArrayList</code>、<code>LinkedList</code> 则是它的实现类（可理解为员工在合同下工作）。也就是说，<code>ArrayList</code>、<code>LinkedList</code> 两个类都需要重写 <code>List</code> 中的方法。</p>
<h2>2.ArrayList 的理解</h2>
<h3>2.1 什么是 ArrayList</h3>
<p>先从图片上引入对 <code>ArrayList</code> 的概念，其实就是数组
<img src="./LinkedList2.webp" alt="" /></p>
<p><code>ArrayList</code> 中文称为顺序表，</p>
<p>从物理意义上：它是用一段<strong>连续的物理内存</strong>（数组）来表达线性关系的。
<img src="./LinkedList3.webp" alt="" /></p>
<p>从源码上看主要是这几个常量，包括容量，数组，实际大小<code>usedSize</code>...</p>
<h3>2.2 ArrayList 的模拟实现</h3>
<h4>2.2.1 实现 MyArrayList 类</h4>
<p>我们尝试模拟实现一下 <code>ArrayList</code> 的主要功能，定义一个 <code>MyArrayList</code> 类</p>
<p>具体代码可访问<a href="https://github.com/Sirens007/MyStorage/blob/main/JavaCode/2026_01_11_java/src/arrayList/MyArrayList.java">GitHub远程仓库</a></p>
<p>首先创建一个模拟 <code>IList</code> 接口，根据接口定义的方法以及 <code>ArrayList</code> 的源码去模拟实现<code>MyArrayList</code> 类</p>
<pre><code>public interface IList {
    // 新增元素,默认在数组最后新增
    public void add(int data);
    // 在 pos 位置新增元素
    public void add(int pos, int data);
    // 判断数组是否已满
    public boolean isFull();
    // 判定是否包含某个元素
    public boolean contains(int toFind);
    // 查找某个元素对应的位置
    public int indexOf(int toFind);
    // 获取 pos 位置的元素
    public int get(int pos);
    // 给 pos 位置的元素设为 value
    public void set(int pos, int value);
    //删除第一次出现的关键字key
    public void remove(int toRemove);
    // 获取顺序表长度
    public int size() ;
    // 清空顺序表
    public void clear();
    // 打印顺序表，注意：该方法并不是顺序表中的方法，为了方便看测试结果给出的
    public void display() ;
}
</code></pre>
<p><code>MyArrayList</code> 类有以下变量及构造方法</p>
<pre><code>public class MyArrayList implements IList{
    public int[] array;
    public int usedSize;
    public static final int DEFAULT_CAPACITY = 10;

    public MyArrayList(){
        array = new int[DEFAULT_CAPACITY];
    }
}
</code></pre>
<p>由于 <code>ArrayList</code> 官方源码是适配多种类型而使用泛型，我们为方便理解就从整型去创建。</p>
<p>其涉及的相关题目可以有 杨辉三角的实现，简单的洗牌算法<a href="https://github.com/Sirens007/MyStorage/blob/main/JavaCode/2026_01_11_java/src/PlayingCards.java">参考源码</a>，了解完之后就对 <code>ArrayList</code> 有初步理解了</p>
<h3>2.3ArrayList 的局限性</h3>
<p>由于 <code>ArrayList</code> 底层代码是一段连续空间，当在 <code>ArrayList</code> 任意位置插入或者删除元素时，就需要将后续元素整体往前或者往后搬移，时间复杂度为 <code>O(n)</code>，效率较低。</p>
<h2>3.链表 与 LinkedList</h2>
<h3>3.1 链表的概念</h3>
<p>链表是物理存储结构上非连存储结构，数据元素的逻辑顺序是通过链表中的引用链接次序实现的。每个节点存储了当前节点的 <code>value</code> 以及对下一个节点的索引</p>
<p>其结构类似图中内容
<img src="./LinkedList4.webp" alt="" /></p>
<p>实际中链表结构又是多样的：</p>
<ol>
<li>单项或者双向</li>
<li>带头或者不带头</li>
<li>循环或者非循环</li>
</ol>
<p><strong>无头单向非循环链表</strong>：结构简单，一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构，如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。</p>
<p>因此我们以单向无头非循环链表举例。</p>
<h3>3.2 链表的模拟实现</h3>
<h4>3.2.1 自创 MySingleList 类</h4>
<p>实现的代码可访问该链接 -&gt; <a href="https://github.com/Sirens007/MyStorage/blob/main/JavaCode/2026_03_04_java/src/mySingleList/MySingleList.java">参考源码</a></p>
<p>我们同样可以实现上面的 IList 接口，其变量、内部类以及构造方法有以下</p>
<pre><code>public class MySingleList implements IList{
    // 创建整个链表中的各个节点（车厢）
    static class ListNode {
        public int val;
        public ListNode next;	// 对下一个节点的索引
        // 初始化各个节点
        public ListNode(int val) {
            this.val = val;
        }
    }
    // 存储头节点引用（火车头）
    public ListNode head;
}
</code></pre>
<p>主要方法有头插法 <code>addFirst()</code>、尾插法 <code>addLast()</code>、任意位置插入 <code>addIndex()</code>、是否包含 <code>contains()</code>、删除节点 <code>remove()</code>等等</p>
<p>我们选部分了解：</p>
<p>头插法：
<img src="./LinkedList5.webp" alt="" /></p>
<pre><code>public void addFirst(int data) {
    ListNode node = new ListNode(data);
    node.next = this.head;
    this.head = node;    
}
</code></pre>
<p>即在头节点前插入，时间复杂度为 <code>O(1)</code>，而 ArrayList 中为 <code>O(n)</code></p>
<hr />
<p>尾插法：
<img src="./LinkedList6.webp" alt="" /></p>
<pre><code>public void addLast(int data) {
    ListNode node = new ListNode(data);
    if(this.head == null){
        this.head = node;
        return;
    }
    ListNode cur = this.head;
    // cur.next != null可以走到倒数第二个节点
    // 但最后一个节点地址可获取
    while(cur.next != null){
        cur = cur.next;
    }
    cur.next = node;
}
</code></pre>
<p>即在最后节点插入，注意最后节点的 <code>next</code> 必须置空</p>
<p>另外补充，官方在链表中增加新节点是默认使用尾插</p>
<hr />
<p>删除节点：
<img src="./LinkedList7.webp" alt="" /></p>
<pre><code>@Override
public void remove(int key) {
    if(this.head == null){
        return;
    }
    if(this.head.val == key){
        this.head = this.head.next;
        return;
    }
    // 创建当前节点cur
    ListNode cur = FindNodeBeforeKey(key);
    cur.next = cur.next.next;
}

// 查找到删除节点前的一个节点
private ListNode FindNodeBeforeKey(int key){
    // 创建当前节点
    ListNode cur = this.head;
    // cur要每一个都走一遍
    while(cur.next != null){
        if(cur.next.val == key){
            return cur;
        }
        cur = cur.next;
    }
    return null;
}
</code></pre>
<p>利用删除节点的前一节点访问到删除节点的 <code>val</code> 值去匹配 <code>key</code>，以及当前节点 <code>cur</code> 的 <code>next</code> 索引的修改</p>
<h3>3.3 单链表相关的 OJ 面试题</h3>
<ol>
<li>
<p>删除链表中等于给定值 val 的所有节点。<a href="https://leetcode.cn/problems/remove-linked-list-elements/">Leecode 移除链表元素</a></p>
</li>
<li>
<p>反转一个单链表。<a href="https://leetcode.cn/problems/reverse-linked-list/description/">Leecode 反转一个单链表</a></p>
</li>
<li>
<p>给定一个带有头结点 head 的非空单链表，返回链表的中间结点。如果有两个中间结点，则返回第二个中间结点。<a href="https://leetcode-cn.com/problems/middle-of-the-linked-list/description/">Leecode 链表的中间节点</a></p>
</li>
<li>
<p>输入一个链表，输出该链表中倒数第k个结点。 <a href="https://leetcode.cn/problems/kth-node-from-end-of-list-lcci/">Leecode 返回倒数第k个节点</a></p>
</li>
<li>
<p>将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。<a href="https://leetcode.cn/problems/merge-two-sorted-lists/description/">Leecode 合并两个有序链表</a></p>
</li>
<li>
<p>编写代码，以给定值x为基准将链表分割成两部分，所有小于x的结点排在大于或等于x的结点之前 。<a href="https://leetcode.cn/problems/partition-list-lcci/">Leecode 分割链表</a></p>
</li>
<li>
<p>链表的回文结构。<a href="https://leetcode.cn/problems/palindrome-linked-list/">Leecode 回文链表</a></p>
</li>
<li>
<p>输入两个链表，找出它们的第一个公共结点。<a href="https://leetcode.cn/problems/intersection-of-two-linked-lists/">Leecode 相交链表</a></p>
</li>
</ol>
<p>9.给定一个链表，判断链表中是否有环。<a href="https://leetcode.cn/problems/linked-list-cycle/">Leecode 环形链表</a></p>
<p>尝试上面的 Oj 题后，相信你对链表有自己的见解了，以上 Oj 题我会同步发出题解文章</p>
<h3>3.4 LinkedList 的结构</h3>
<p><code>LinkedList</code> 的底层是双向链表结构，由于链表没有将元素存储在连续的空间中，元素存储在单独的节点中，然后通过引用将节点连接起来了，因此在任意位置插入或者删除元素时，不需要搬移元素，效率比较高。</p>
<p>以下图片即为双向链表结构，包含了 <code>val</code>、<code>prev</code> 前驱、<code>next</code> 后驱以及 <code>head</code> 头指针和尾指针
<img src="./LinkedList8.webp" alt="" /></p>
<p>通过以下集合框架可以发现，<code>LinkedList</code> 也同样实现了 List 接口：
<img src="./LinkedList9.webp" alt="" /></p>
<p>说明：</p>
<blockquote>
<p>1.LinkedList 实现了 List 接口</p>
<p>2.LinkedList 的底层使用了双向链表</p>
<p>3.LinkedList 没有实现 RandomAccess 接口，因此 LinkedList 不支持随机访问</p>
<p>4.LinkedList 的任意位置插入和删除时效率比较高，时间复杂度为 O(1)</p>
<p>5.LinkedList 比较适合任意位置插入的场景</p>
</blockquote>
<h3>3.5 LinkedList 的模拟实现</h3>
<h4>3.5.1 实现 MyLinkedList 类</h4>
<p>和 <code>ArrayList</code>、单向链表一样，同样实现 <code>List</code> 接口，在此我们就模拟实现 <code>IList</code> 接口</p>
<pre><code>public class MyLinkedList implements IList {
    // 内部类-各个节点
    class ListNode{
        public int val;
        public ListNode prev;	// 前驱
        public ListNode next;	// 后驱

        public ListNode(int val) {
            this.val = val;
        }
    }
    public ListNode head;
    public ListNode last;
}
</code></pre>
<p>与链表类似，但删除时不再需要提前一个节点，我们可直接到要删除的节点位置</p>
<p><code>remove()</code> - 删除方法：</p>
<pre><code>@Override
public void remove(int key) {
    ListNode cur = head;
    while(cur != null){
        if(cur.val == key){
            //删除在开头
            if(cur == head){
                head = head.next;
                if (head != null) {
                    // 情况 A: 至少还有两个节点，现在的 head 指向原第二个节点
                    head.prev = null;
                } else {
                    // 情况 B: 原本只有一个节点，删完后 head 为 null
                    // 必须同步清理 last
                    last = null;
                }
            } else{
                //删除在其他（中间、结尾）
                cur.prev.next = cur.next;
                if(cur.next == null){
                    last = last.prev;  // 该情况为删除在结尾
                } else{
                    cur.next.prev = cur.prev;
                }
            }
             return;
        }
        cur = cur.next;
    }
}
</code></pre>
<p>如代码中注释，删除位置首先分为两处--</p>
<ol>
<li>开头、2. 中间和结尾</li>
</ol>
<p>随后依次在分类讨论情况，其常用方法与 <code>ArrayList</code> 类似</p>
<h3>3.6ArrayList、链表、LinkedList 关系</h3>
<p><strong>线性表 (Linear List)</strong> —— <em>“我们要排成一队”</em> (逻辑协议)</p>
<p>├── <strong>顺序表 (Sequential List)</strong> —— <em>“大家必须坐连排座”</em> (底层是数组)</p>
<p>└── <strong>链表 (Linked List)</strong> —— <em>“大家随便坐，手拉手就行”</em> (底层是节点)</p>
<p>├── <strong>单向链表</strong> —— <em>“只知道后面是谁”</em></p>
<p>└── <strong>双向链表</strong> —— <em>“既知道前面，也知道后面”</em></p>
<table>
<thead>
<tr>
<th>概念名称</th>
<th>属于什么</th>
<th>物理结构</th>
<th>在 Java 中的“肉身”</th>
</tr>
</thead>
<tbody>
<tr>
<td>顺序表</td>
<td>线性表的一种实现</td>
<td>连续数组</td>
<td>ArrayList</td>
</tr>
<tr>
<td>链表</td>
<td>线性表的一种实现</td>
<td>分散节点 + 指针</td>
<td>LinkedList</td>
</tr>
<tr>
<td>链表节点</td>
<td>链表的组成零件</td>
<td>包含数据和指针的对象</td>
<td>ListNode类</td>
</tr>
</tbody>
</table>
<p>以上是我关于Java的笔记分享</p>
<p>感谢你读到这里，这也是我学习路上的一个小小记录。希望以后回头看时，能看到自己的成长~</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据结构与算法"/>
  </entry>
  <entry>
    <title>栈与队列：核心操作、循环队列与典型应用</title>
    <link href="https://blog.syrena.cn/posts/stackandqueue/stackandqueue/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/stackandqueue/stackandqueue/</id>
    <published>2026-03-27T00:00:00.000Z</published>
    <updated>2026-03-27T00:00:00.000Z</updated>
    <summary>梳理 Java 中栈（Stack）与队列（Queue）的基础概念，包含物理结构对比、用数组模拟实现循环队列，以及有效的括号等经典 LeetCode 题解思路.</summary>
    <content type="html"><![CDATA[<h2>1.栈 Stack</h2>
<h3>1.1 栈？</h3>
<p>栈：是一种特殊的线性表，他<strong>只允许在固定的一端进行插入和删除元素操作</strong>。进行数据插入和删除操作的一端称为<strong>栈顶</strong>，另一端称为<strong>栈底</strong>。栈中的元素遵循<strong>先进后出的原则</strong>。</p>
<p>压栈：数据进入栈中称为进栈/压栈/入栈</p>
<p>出栈：栈的删除操作叫做出栈</p>
<p>其概念图如下：
<img src="./StackAndQueue1.webp" alt="" /></p>
<h3>1.2 队列的三种实现</h3>
<p>栈的方法不多，我们可以查看查看 Stack 源码中的方法
<img src="./StackAndQueue2.webp" alt="" /></p>
<p>其方法说明如下</p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>返回类型</strong></th>
<th><strong>功能</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>push(E e)</strong></td>
<td>E</td>
<td>将 e 入栈，并返回 e</td>
</tr>
<tr>
<td><strong>pop()</strong></td>
<td>E</td>
<td>将栈顶元素出栈并返回</td>
</tr>
<tr>
<td><strong>peek()</strong></td>
<td>E</td>
<td>获取栈顶元素</td>
</tr>
<tr>
<td><strong>size()</strong></td>
<td>int</td>
<td>获取栈中有效元素个数</td>
</tr>
<tr>
<td><strong>empty()</strong></td>
<td>boolean</td>
<td>检测栈是否为空</td>
</tr>
</tbody>
</table>
<p>Java 中<strong>栈的常见实现</strong>有三种：</p>
<table>
<thead>
<tr>
<th><strong>实现方式</strong></th>
<th><strong>备注</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Stack</strong></td>
<td>老旧，但线程安全（带锁），性能相对较低</td>
</tr>
<tr>
<td><strong>ArrayDeque</strong></td>
<td>非线程安全，性能极高</td>
</tr>
<tr>
<td><strong>LinkedList</strong></td>
<td>可当作栈使用，但作为栈使用时，性能通常略逊于ArrayDeque</td>
</tr>
</tbody>
</table>
<p>要想了解栈，模拟栈的实现是很有必要的</p>
<pre><code>package myStack;
import exception.EmptyStackException;
import java.util.Arrays;

public class MyStack {
    public int[] elem;
    public int usedSize;

    public MyStack() {
        this.elem = new int[10];
    }

    public void push(int val){
        if(isFull()){
            this.elem = Arrays.copyOf(elem,2*elem.length);
        }
        elem[usedSize++] = val;
    }

    public boolean isFull(){
        return usedSize == elem.length;
    }

    public int pop(){
        if(isEmpty()){
            throw new EmptyStackException("栈中元素为空");
        }
        int val = elem[usedSize - 1];   //取得栈中的最后值
        usedSize--;    //取消对最后值的索引
        return val;
    }

    public int peek(){
        if(isEmpty()){
            throw new EmptyStackException("栈中元素为空");
        }
        return elem[usedSize - 1];
    }

    public boolean isEmpty(){
        return usedSize == 0;
    }

    public int search(int val){
        for (int i = usedSize - 1;i &gt;= 0;i--) {
            if(elem[i] == val){
                // 返回距离栈顶的距离。栈顶本身返回 1，其下方返回 2，以此类推
                return usedSize - i;
            }
        }
        return -1;
    }
}
</code></pre>
<p>代码可详见于<a href="https://github.com/Sirens007/MyStorage/tree/main/JavaCode/2026_03_04_java/src/myStack">远程仓库</a></p>
<h3>1.3 Stack 典型 Oj 题</h3>
<p><a href="https://leetcode-cn.com/problems/valid-parentheses/">20. 有效的括号 - 力扣（LeetCode）</a></p>
<p><a href="https://leetcode-cn.com/problems/evaluate-reverse-polish-notation/">150. 逆波兰表达式求值 - 力扣（LeetCode）</a></p>
<p><a href="https://leetcode-cn.com/problems/min-stack/">Leecode 最小栈</a></p>
<p><a href="https://leetcode.cn/problems/implement-stack-using-queues/">Leecode 用队列实现栈</a></p>
<p><a href="https://leetcode.cn/problems/implement-queue-using-stacks/">Leecode 用栈实现队列</a></p>
<h4>有效的括号</h4>
<p>对于此题，我们可以先创建几个例子，如<code>([])</code>、<code>([)]</code>、<code>(()</code>据此可以构建该模型。</p>
<blockquote>
<p>既然是要左括号匹配右括号，我们可以思考，假设先遇到左括号正常返回 true 的例子，并能够以 <strong>最近遇到的左括号</strong> 去匹配 <strong>右括号</strong>，只能是通过 <strong>栈（Stack）</strong> 这个数据结构来完成了
<img src="./StackAndQueue3.webp" alt="" /></p>
</blockquote>
<p>（从左至右）如图 1</p>
<blockquote>
<p>遍历字符串，if {先遇到左括号，push 压栈；}</p>
<p>else {遇到右括号，</p>
<p>if{ stack 为空，返回 false 结束}</p>
<p>else {栈不为空,peek()匹配，如匹配则 pop()弹出，否则 false}</p>
<p>}</p>
</blockquote>
<p>注：以上为此题抽象思路,但稍有不足，假设左括号过多（如图三），我们还需最后检查栈是否为空</p>
<pre><code>// 有效的括号
public boolean isValid(String s) {
    Stack&lt;Character&gt; stack = new Stack&lt;&gt;();
    for (int i = 0; i &lt; s.length(); i++) {
        char ch = s.charAt(i);
        if(ch == '(' || ch == '[' || ch == '{') {
            // 如果匹配，进行压栈
            stack.push(ch);
        } else{
            if(stack.empty()) {
                return false;
            }
            // 若不为空，继续判断是否匹配
            char ch2 = stack.peek();
            // 从栈中取出元素,即不走上面的if了，为右括号
            if(ch == ')' &amp;&amp; ch2 == '('
                    || ch == '}' &amp;&amp; ch2 == '{'
                    || ch == ']' &amp;&amp; ch2 =='['){
                stack.pop();
            }else {
                // 若不匹配上述
                return false;
            }
        }
    }
    //处理匹配完右括号后，左括号是否多出来
    if(!stack.empty()){
        return false;
    }
    return true;
}
</code></pre>
<h4>逆波兰表达式求值</h4>
<p>我们先简单了解以下波兰表达式概念，如下</p>
<p>给出一个中缀表达式：<code>a + b * c + (d * e + f) * g</code>,</p>
<p>转换为后缀表达式：<code>a b c * + d e * f + g * +</code>.</p>
<p>其转换过程如下：将以下每个运算符号都放在括号中，
<img src="./StackAndQueue4.webp" alt="" /></p>
<p>随后将每个运算符号移除当前所处的括号外，得到
<img src="./StackAndQueue5.webp" alt="" /></p>
<p>将括号去掉便得到我们的后缀表达式了，而如何按照原先规则运算就是我们这次讲的题了</p>
<blockquote>
<p><strong>输入</strong>：tokens = ["2","1","+","3","*"]</p>
<p><strong>输出</strong>：9</p>
<p><strong>解释</strong>：该算式转化为常见的中缀算术表达式为：((2 + 1) * 3) = 9</p>
</blockquote>
<p>题目给出这样一个示例，可以想到每遍历到<strong>操作数</strong>就提取两个数字</p>
<blockquote>
<p>根据上述思路，我们可以想到用栈来存储每次遍历时遇到的数字，当遇到运算符时，就弹出栈顶的两个元素进行该运算符的计算</p>
</blockquote>
<p>注：注意两个数字的位置不要搞错！第一个数字是右操作数，第二个数字是左操作数</p>
<p>由上述思路得出</p>
<pre><code>// 逆波兰表达式求值
public int evalRPN(String[] tokens) {
    Stack&lt;Integer&gt; stack = new Stack&lt;&gt;();
    // 遍历字符串
    for(String x : tokens){
        // 匹配是否是操作符号
        if(!isOperator(x)){
            // 如果是数字就压栈
            int num = Integer.parseInt(x);
            stack.push(num);
        } else{
            // 不是数字就运算
            int val2 = stack.pop();
            int val1 = stack.pop();
            switch(x){
                case"+":
                    stack.push(val1+val2);
                    break;
                case"-":
                    stack.push(val1-val2);
                    break;
                case"*":
                    stack.push(val1*val2);
                    break;
                case"/":
                    stack.push(val1/val2);
                    break;
            }
        }
    }
    return stack.pop();
}

private boolean isOperator(String ch){
    if(ch.equals("+") || ch.equals("-")
            || ch.equals("*") || ch.equals("/")){
        return true;
    }
    return false;
}
</code></pre>
<h3>1.4 栈、虚拟机栈、栈帧的区分</h3>
<p><strong>栈</strong></p>
<blockquote>
<p>由本节知道，栈是一种数据结构，遵循先进后出的原则</p>
</blockquote>
<p><strong>虚拟机栈</strong></p>
<blockquote>
<ol>
<li>虚拟机栈是 JVM 的一块运行时内存区域</li>
<li>其中，虚拟机栈 = 每个线程私有的调用栈</li>
<li><strong>虚拟机栈</strong>主要存的就是我们要说的<strong>栈帧</strong></li>
</ol>
</blockquote>
<p><strong>栈帧</strong></p>
<blockquote>
<p>栈帧是<strong>方法运行时的数据结构</strong>，即<strong>每次方法调用产生的数据单元</strong></p>
<p>也就是说：每调用一个方法 -&gt; 创建一个栈帧</p>
<p>一个栈帧里通常包含：局部变量表、操作数栈、动态链表、方法返回地址</p>
</blockquote>
<h2>2.队列 Queue</h2>
<h3>2.1 什么叫队列</h3>
<p>队列：只允许在<strong>队尾</strong>进行<strong>元素插入</strong>，在<strong>队头</strong>进行<strong>元素删除</strong>的一种特殊线性表，队列遵循<strong>先进先出的原则</strong>。</p>
<p>对于队列的概念我们可以抽象理解为如下图，类似倒置的栈，但与栈不同
<img src="./StackAndQueue6.webp" alt="" /></p>
<h3>2.2 如何使用队列</h3>
<p>java 中，队列与栈不同的是，<strong>栈是一个类</strong>，而<strong>队列是一个接口</strong>。</p>
<p>因此我们实现队列的时候就不能像栈一样 new 一个它本身，而是要通过常用的<strong>LinkedList</strong>(双向链表)、<strong>ArrayDeque</strong>(动态数组)、<strong>PriorityQueue</strong>(堆)...实现类来实现。</p>
<pre><code>Queue&lt;Integer&gt; q = new LinkedList&lt;&gt;();
Queue&lt;Integer&gt; q = new ArrayDeque&lt;&gt;();
Queue&lt;Integer&gt; q = new PriorityQueue&lt;&gt;();
...
</code></pre>
<p>队列 Queue 的<strong>结构</strong>如下
<img src="./StackAndQueue7.webp" alt="" /></p>
<p><strong>队列的基本操作方法：</strong></p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>功能</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>boolean offer(E e)/add(E e)</td>
<td>入队 enqueue</td>
</tr>
<tr>
<td>poll()/remove(): E</td>
<td>出队 dequeue</td>
</tr>
<tr>
<td>peek()/element(): E</td>
<td>查看队头</td>
</tr>
<tr>
<td>boolean isEmpty()</td>
<td>检查队列是否为空</td>
</tr>
<tr>
<td>int size()</td>
<td>获取队列中的有效元素个数</td>
</tr>
</tbody>
</table>
<p>其中，<code>isEmpty()</code> <code>size()</code>这两个方法是定义在父类 Collection 中。</p>
<p>另外上面存在两种一样功能的方法区别差异不大</p>
<h3>2.3 模拟实现队列 Queue</h3>
<p>在了解完队列的方法后，进一步学习其方法，加深对队列的理解</p>
<p>补充：</p>
<p><strong>顺序结构</strong></p>
<blockquote>
<p>数据在内存中连续存储</p>
</blockquote>
<p><strong>链式结构</strong></p>
<blockquote>
<p>数据在内存中不需要连续，每个元素通过指针（应用连接起来）</p>
</blockquote>
<p>在前面学习完的顺序结构（数组、ArrayList...）和 链式结构（LinkedList、单链表...）之后，对于队列的实现，我们在后面会说明使用哪个实现会更好</p>
<p>队列的方法十分简单，代码如下，没有很复杂的逻辑。</p>
<pre><code>public class MyQueue {

    static class ListNode {
        public int val;
        public ListNode prev;
        public ListNode next;

        public ListNode(int val) {
            this.val = val;
        }
    }
    public ListNode first = null;
    public ListNode last = null;
    public int usedSize = 0;

    // 入队列（尾入）
    public void offer(int val) {
        ListNode node = new ListNode(val);
        if(isEmpty()) {
            first = last = node;
        } else {
            last.next = node;
            node.prev = last;
            last = last.next;
        }
        usedSize++;
    }

    // 出队列（头出）
    public int poll() {
        if(isEmpty()) {
            return -1;
        }
        int val = first.val;
        first = first.next;
        if(first != null) {
            // 将first的前驱置空
            first.prev = null;
        }
        usedSize--;
        return val;
    }

    // 获取队头元素
    public int peek() {
        if(isEmpty()) {
            return -1;
        }
        return first.val;
    }

    public boolean isEmpty() {
        return usedSize == 0;
    }
}
</code></pre>
<p>从逻辑上，顺序结构（数组）、链式结构（单、双链表）都可以实现队列 Queue。</p>
<p>我们可以对比一下</p>
<h4>1.数组实现</h4>
<blockquote>
<ol>
<li>队头需要 front 标记，队尾需要 rear 标记</li>
<li>必须使用循环数组才能充分利用空间</li>
</ol>
</blockquote>
<p>时间复杂度</p>
<p>入队：	 O(1)</p>
<p>出队：	 O(1)</p>
<p>随机访问：O(1)</p>
<p>可以发现其优/缺点：</p>
<blockquote>
<p>优：内存连续，空间占用少、随机访问快</p>
<p>缺：必须事先指定数组容量或动态扩容</p>
</blockquote>
<h4>2.单链表实现</h4>
<blockquote>
<p>队头 head 指向队头节点，last 指向队尾节点</p>
<p>入队采用尾插法</p>
<p>出队可删除头节点</p>
</blockquote>
<p>注：就算有尾节点的标记，也不能从头节点入队，因为删除还是需要找对后一个节点的前一个节点</p>
<p>时间复杂度</p>
<p>入队：	 O(1)</p>
<p>出队：	 O(1)</p>
<p>随机访问：O(n)</p>
<h4>3.双链表实现</h4>
<p>与单链表相同</p>
<blockquote>
<p>队头 head 指向队头节点，last 指向队尾节点</p>
<p>入队采用尾插法</p>
<p>出队可删除头节点</p>
</blockquote>
<p>时间复杂度</p>
<p>入队：	 O(1)</p>
<p>出队：	 O(1)</p>
<p>随机访问：O(n)</p>
<p>但是双链表可以双向遍历，支持在两端操作</p>
<table>
<thead>
<tr>
<th><strong>特性</strong></th>
<th><strong>数组</strong></th>
<th><strong>单链表</strong></th>
<th><strong>双链表</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>存储</td>
<td>连续</td>
<td>不连续</td>
<td>不连续</td>
</tr>
<tr>
<td>内存占用</td>
<td>少</td>
<td>中</td>
<td>多</td>
</tr>
<tr>
<td>入队/出队</td>
<td>O(1)</td>
<td>O(1)</td>
<td>O(1)</td>
</tr>
<tr>
<td>随机访问</td>
<td>O(1)</td>
<td>O(n)</td>
<td>O(n)</td>
</tr>
<tr>
<td>是否需要扩容</td>
<td>是（或固定容量）</td>
<td>否</td>
<td>否</td>
</tr>
<tr>
<td>CPU缓存友好</td>
<td>高</td>
<td>低</td>
<td>低</td>
</tr>
<tr>
<td>适用场景</td>
<td>高性能、容量固定或可扩容</td>
<td>容量不确定、出入队灵活</td>
<td>双端操作、容量不确定</td>
</tr>
</tbody>
</table>
<h3>2.4 循环队列</h3>
<p><img src="./StackAndQueue8.webp" alt="" />
循环队列：是顺序队列的一种优化，它解决了顺序队列出对后空间浪费的问题。</p>
<blockquote>
<ol>
<li>普通顺序队列：出队后，队头向后移动，数组前面出现空闲空间，但无法复用</li>
<li>循环队列：把数组看作 <strong>环形</strong>，当 <code>rear</code> 到达数组末尾时，如果前面有空余位置，就可以回到数组开头继续存放元素</li>
</ol>
</blockquote>
<p>对于循环队列，我们给出以下定义</p>
<blockquote>
<p>队列用数组存储<br />
front 指向队头<br />
rear 指向队尾的下一个位置</p>
</blockquote>
<p>由此，我们可以得到<strong>循环公式</strong></p>
<blockquote>
<p>入队时：<code>rear = (rear + 1) % capacity</code></p>
<p>出队时：<code>front = (front + 1) % capacity</code></p>
<p>队列满条件：<code>(rear + 1) % capacity == front</code></p>
<p>队列空条件：<code>rear == front</code></p>
</blockquote>
<p>对于循环数组判断是否已满有三种方法</p>
<blockquote>
<ol>
<li>通过添加 usedSize 属性记录</li>
<li>保留一个位置</li>
<li>使用标记，定义 boolean 类型元素</li>
</ol>
</blockquote>
<p>我们拿第三个标记法举例</p>
<pre><code>// 使用不浪费空间方法
class MyCircularQueue{
    public int front;
    public int rear;
    public int[] elem;
    private boolean isFullFlg;
    public MyCircularQueue(int k) {
        elem = new int[k];
        front = 0;
        rear = 0;
        isFullFlg = false;
    }
    // 入队
    public boolean enQueue(int value) {
        if(isFull()) {
            return false;
        }
        elem[rear] = value;
        rear = (rear + 1) % elem.length;    // rear往后移一位
        if(rear == front) {
            isFullFlg = true;
        }
        return true;
    }
    // 出队
    public boolean deQueue() {
        if(isEmpty()) {
            return false;
        }
        front = (front + 1) % elem.length;  // front往后移一位
        isFullFlg = false;  // 只要成功出队，则队列不可能满
        return true;
    }

    // 返回front指向的元素
    public int Front() {
        if(isEmpty()) return -1;
        return elem[front];
    }

    // 返回rear指向的元素
    public int Rear() {
        if(isEmpty()) return -1;
        int index = (rear == 0) ? elem.length - 1 : rear - 1;
        return elem[index];
    }

    public boolean isEmpty() {
        return (rear == front) &amp;&amp; !isFullFlg;
    }

    public boolean isFull() {
        return isFullFlg;
    }
}
</code></pre>
<p><a href="https://leetcode.cn/problems/design-circular-queue/">622. 设计循环队列 - 力扣（LeetCode）</a></p>
<h3>2.5 双端队列 Deque</h3>
<p><strong>Deque</strong> = <strong>D</strong>ouble <strong>E</strong>nded <strong>Que</strong>ue</p>
<p>双端队列是指允许两端都可以进行入队和出队操作的队列。即说明元素可从队头出入队，也可以从队尾出入队。</p>
<p>双端队列可以用 <strong>顺序结构</strong> 或 <strong>链式结构</strong> 实现</p>
<pre><code>Deque&lt;Integer&gt; stack = new ArrayDeque&lt;&gt;();//双端队列的线性实现
Deque&lt;Integer&gt; queue = new LinkedList&lt;&gt;();//双端队列的链式实现
</code></pre>
<p>以上是我关于Java的笔记分享</p>
<p>感谢你读到这里，这也是我学习路上的一个小小记录。希望以后回头看时，能看到自己的成长~</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据结构与算法"/>
  </entry>
  <entry>
    <title>算法复杂度：大 O 表示法、时间空间分析与平均情况</title>
    <link href="https://blog.syrena.cn/posts/time-space-complexity/time-space-complexity/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/time-space-complexity/time-space-complexity/</id>
    <published>2026-03-13T00:00:00.000Z</published>
    <updated>2026-03-13T00:00:00.000Z</updated>
    <summary>系统讲解时间与空间复杂度，从大 O 表示法到典型算法推导，重点分析平均时间复杂度的计算方法与实际案例.</summary>
    <content type="html"><![CDATA[<p>算法效率分析分为两种：第一种是<strong>时间效率</strong>，第二种是<strong>空间效率</strong>。时间效率被称为时间复杂度，而空间效率被称为空间复杂度，时间复杂度主要衡量的是一个算法的运行速度，而空间复杂度主要衡量一个算法所需要的额外空间。</p>
<h2>1.时间复杂度</h2>
<h3>1.1 什么是时间复杂度</h3>
<p>算法的时间复杂度是<strong>算法执行步骤数量随输入规模 n 增长的趋势</strong>，在程序中常用<strong>大 O 渐进表示法</strong>来表示。</p>
<p>而之所以用大 O 表示法的原因是：</p>
<blockquote>
<p>当 n 足够大时</p>
<p>低阶项和常数项影响可以忽略</p>
<p>决定性能的是“最高阶项”</p>
</blockquote>
<h3>1.2 常见特殊的时间复杂度计算举例</h3>
<p><strong>【示例 1】</strong></p>
<pre><code>//计算binarySearch二分查找法的时间复杂度？
int binarySearch(int[] array, int value) {
    int begin = 0;
    int end = array.length - 1;
    while (begin &lt;= end) {
        int mid = begin + ((end-begin) / 2);
        if (array[mid] &lt; value)
            begin = mid + 1;
        else if (array[mid] &gt; value)
            end = mid - 1;
        else
            return mid;
    }
    return -1;
}
</code></pre>
<p>最坏情况是：一条直线，一直被不断的分为 1/2，最终分成只剩一个，其推导过程如下</p>
<blockquote>
<p>第一次：n/2^1</p>
<p>第二次：n/2^2</p>
<p>第三次：n/2^3...</p>
<p>第x 次：n/2^x    -&gt;  n/2^x = 1   -&gt; x = log&lt;sub&gt;2&lt;/sub&gt;N，最终时间复杂度为 O(log&lt;sub&gt;2&lt;/sub&gt;N)</p>
</blockquote>
<p><strong>【示例 2】</strong></p>
<pre><code>//计算阶乘递归factoria的时间复杂度？
long factorial(int N){
    return N&lt;2 ? N:factorial(N-1)*N;
}
</code></pre>
<p>计算公式：<strong>递归的时间复杂度 = 递归的次数 * 每次递归执行的次数</strong></p>
<p>该样例计算即：</p>
<blockquote>
<p>当 N = 1,时则不再递归，因此递归次数即为 N - 1，</p>
<p>而每次递归执行的语句为三目运算符语句，其执行次数可视为 1，因此为 O(N)</p>
</blockquote>
<p><strong>【示例 3】</strong></p>
<pre><code>//计算斐波那契递归fibnacci的时间复杂度？
int fibnacci(int N){
    return N&lt;2 ? fibnacci(N-1)+fibnacci(N-2);
}
</code></pre>
<p><img src="./Time-Space-complexity1.webp" alt="" /></p>
<p>以上为粗略计算过程，因此其递归次数就为等比数列求和即可，最终为 <strong>O(2^n)</strong></p>
<h3>1.3 计算时间复杂度的平均情况</h3>
<p>时间复杂度平均情况是：在所有可能输入中，按每种输入出现的概率加权后，算法执行时间的数学期望。</p>
<p>其公式表达情况为：
<img src="./Time-Space-complexity2.svg" alt="" /></p>
<blockquote>
<ul>
<li>T&lt;sub&gt;i&lt;/sub&gt;(n)：第 i 种情况的执行步数</li>
<li>P&lt;sub&gt;i&lt;/sub&gt;：该情况发生的概率</li>
<li>∑ P&lt;sub&gt;i&lt;/sub&gt;=1</li>
</ul>
</blockquote>
<p>我们举一个范例，比如线性查找 target：</p>
<pre><code>for (int i = 0; i &lt; n; i++) {
    if (arr[i] == target) return i;
}
return -1;
</code></pre>
<table>
<thead>
<tr>
<th>情况</th>
<th>target 位置</th>
<th>比较次数</th>
</tr>
</thead>
<tbody>
<tr>
<td>第 1 个</td>
<td>0</td>
<td>1</td>
</tr>
<tr>
<td>第 2 个</td>
<td>1</td>
<td>2</td>
</tr>
<tr>
<td>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr>
<td>第 n 个</td>
<td>n-1</td>
<td>n</td>
</tr>
<tr>
<td>不存在</td>
<td>—</td>
<td>n</td>
</tr>
</tbody>
</table>
<p><strong>常见合理假设：</strong></p>
<blockquote>
<p>target 在数组中等概率出现</p>
<p>每个位置概率为 1/n1/n1/n</p>
<p>或包含“不存在”的情况</p>
</blockquote>
<p><strong>计算数学期望：</strong>
<img src="./Time-Space-complexity3.svg" alt="" /></p>
<p>利用等差数列公式：</p>
<p><img src="./Time-Space-complexity4.svg" alt="" /></p>
<p>最终时间复杂度为 O(n)</p>
<h2>2.空间复杂度</h2>
<h3>2.1 什么是空间复杂度</h3>
<blockquote>
<p>空间复杂度是：算法在执行过程中，<strong>额外</strong>占用的内存空间随输入规模 n <strong>增长的趋势</strong></p>
</blockquote>
<p>因为在比较算法的时候，面对的是同一份输入，因此不关心算法本身的空间，而只为分析<strong>算法为了运行“多申请了多少空间”</strong></p>
<p>以后常遇到的复杂度有：O(1) &lt; O(log&lt;sub&gt;2&lt;/sub&gt;N) &lt; O(N) &lt; O(N*log&lt;sub&gt;2&lt;/sub&gt;N) &lt; O(N&lt;sup&gt;2&lt;/sup&gt;)</p>
<p>其判断复杂度方法与时间复杂度类似，参考时间复杂度即可</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="数据结构与算法"/>
  </entry>
  <entry>
    <title>Java 包装类与泛型：装箱拆箱、类型擦除与边界约束</title>
    <link href="https://blog.syrena.cn/posts/wrapper-generics/wrapper-generics/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/wrapper-generics/wrapper-generics/</id>
    <published>2026-03-13T00:00:00.000Z</published>
    <updated>2026-03-13T00:00:00.000Z</updated>
    <summary>系统梳理 Java 包装类与泛型机制，从装箱拆箱到泛型用法，深入解析类型擦除原理与编译期行为，理解泛型的底层本质.</summary>
    <content type="html"><![CDATA[<h2>1. 包装类</h2>
<h3>1.1 基本数据类型和其对应的包装类</h3>
<p>Java 为了把<strong>基本数据类型</strong>当作“对象”来使用，专门提供的一组<strong>类</strong>。</p>
<table>
<thead>
<tr>
<th><strong>基本类型</strong></th>
<th><strong>包装类</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>byte</td>
<td>Byte</td>
</tr>
<tr>
<td>short</td>
<td>Short</td>
</tr>
<tr>
<td>int</td>
<td>Integer</td>
</tr>
<tr>
<td>long</td>
<td>Long</td>
</tr>
<tr>
<td>float</td>
<td>Float</td>
</tr>
<tr>
<td>double</td>
<td>Double</td>
</tr>
<tr>
<td>char</td>
<td>Character</td>
</tr>
<tr>
<td>boolean</td>
<td>Boolean</td>
</tr>
</tbody>
</table>
<h3>1.2 装箱与拆箱</h3>
<p><strong>装箱（装包）</strong>：把基本数据类型变成包装类类型的过程叫做装箱（装包）。</p>
<p>装箱又分为自动装箱和手动装箱</p>
<p>实际自动装箱底层逻辑还是手动装箱</p>
<pre><code>public class Demo{
    public static void main(String[] args) {
        int a = 100;
        //手动装箱
        Integer ia = Integer.valueOf(a);
        Double da = Double.valueOf(a);
        System.out.println(ia);
        System.out.println(da);

        //自动装箱
        Integer iia = a;
        Double dda = (double) a;
        System.out.println(iia);
        System.out.println(dda);
    }
}
</code></pre>
<p><strong>拆箱（拆包）</strong>：把包装类类型变成基本数据类型的过程就叫做拆箱（拆包）。</p>
<p>拆箱也分为自动拆箱和手动拆箱</p>
<p>实际自动拆箱底层逻辑还是手动拆箱</p>
<pre><code>public class Demo{
    public static void main(String[] args) {
        Integer ia = 100;
        //手动拆箱
        int a = ia.intValue();
        double d = ia.doubleValue();
        System.out.println(a);
        System.out.println(d);

        //自动拆箱
        int iia = ia;
        double da = ia.doubleValue();
        System.out.println(iia);
        System.out.println(da);
    }
}
</code></pre>
<h3>1.3 Integer 缓存机制</h3>
<pre><code>public class Demo {
    public static void main(String[] args) {
        Integer a = 100;
        Integer b = 100;
        System.out.println(a == b);//true
        Integer c = 200;
        Integer d = 200;
        System.out.println(c == d);//false
    }
}
</code></pre>
<p>以上代码中涉及的是自动装包，结果的原因：</p>
<blockquote>
<p><code>Integer.valueOf()</code>会缓存 [-128,127]</p>
<p>在这个范围内：返回同一个对象</p>
<p>超出范围：新对象</p>
</blockquote>
<p>我们可以查看 Integer 源码中的<code>valueOf()</code>方法
<img src="./wrapper-generics1.webp" alt="" /></p>
<p>我们可以看出自动装包输入的 i 在其中是个数组的范围，我们可以查看到 IntegerCache 这样一个类中设定了 <code>low = -128</code>,而<code>high</code>最终被<code>h = 127</code>所赋予，因此得到该缓存数组范围 <code>[-128,127]</code></p>
<p>我们再思考返回的数组内容，<code>IntegerCache.cache[i + (-IntegerCache.low)]</code>，我们就拿 <code>i = 100</code>代入
<img src="./wrapper-generics2.webp" alt="" /></p>
<p>而我们通常比较“值”的方法是使用<code>a.equals(b)</code></p>
<h2>2.泛型</h2>
<p>泛型就是<strong>把“类型”当作参数，在编译期确定下来的一种机制</strong>。</p>
<p>其作用是 1、<strong>解决“类型不安全”问题</strong>，2、<strong>消除强制类型转换（向下转型）</strong>，3、<strong>提高代码复用能力</strong></p>
<h3>2.1 使用语法</h3>
<p><strong>泛型类</strong></p>
<p>列举样例：</p>
<pre><code>class 泛型类名称&lt;&gt;{
//可使用类型参数
}

class ClassName&lt;T1,T2,...,Tn&gt;{
}

class 泛型类名称&lt;类型形参列表&gt;extends 继承类/*此处可使用类型参数*/{
}

class ClassName&lt;T1,T2,...,Tn&gt; extends ParentClass &lt;T1&gt;{
}
</code></pre>
<p>举例：</p>
<pre><code>class Box&lt;T&gt; {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

</code></pre>
<p>使用如下：</p>
<pre><code>Box&lt;String&gt; box = new Box&lt;&gt;();
box.set("hello");
String s = box.get();
</code></pre>
<p>说明：<code>T</code>在定义类时只是占位；在创建对象时才是真正确定类型</p>
<p><strong>泛型方法</strong></p>
<pre><code>方法限定符 &lt;类型形参列表&gt; 返回值类型 方法名称(形参列表) { ... }
</code></pre>
<pre><code>public static &lt;T&gt; T getFirst(T[] arr) {
    return arr[0];
}
</code></pre>
<p>解释：</p>
<blockquote>
<p><code>&lt;T&gt;</code>：位于返回值之前，表示该方法定义了一个泛型类型参数 <code>T</code></p>
<p><code>T</code>：表示这个方法返回值的类型是<code>T</code></p>
</blockquote>
<p>使用如下：</p>
<pre><code>Integer i = getFirst(new Integer[]{1, 2});
String s = getFirst(new String[]{"a", "b"});
</code></pre>
<p><strong>泛型接口</strong></p>
<pre><code>interface Mapper&lt;T&gt; {
    T map(T input);
}
</code></pre>
<p>实现方式一（实现时确定类型）：</p>
<pre><code>class StringMapper implements Mapper&lt;String&gt; {
    public String map(String input) {
        return input.toUpperCase();
    }
}
</code></pre>
<p>实现方式二（实现时仍保留泛型）：</p>
<pre><code>class DefaultMapper&lt;T&gt; implements Mapper&lt;T&gt; {
    public T map(T input) {
        return input;
    }
}
</code></pre>
<h3>2.2 泛型的”本质机制“</h3>
<h4>2.2.1 泛型只存在编译期</h4>
<p>Java 的泛型是”伪泛型“</p>
<p>比如：</p>
<pre><code>ArrayList&lt;String&gt;
ArrayList&lt;Integer&gt;
</code></pre>
<p>在 JVM 看来都是：</p>
<pre><code>ArrayList
</code></pre>
<h4>2.2.2 类型擦除</h4>
<p>编译过程中，将所有的 T 替换为 Object 这种机制称为：<strong>擦除机制</strong></p>
<p>java 的泛型机制是在编译级别实现的。编译器生成的字节码在运行期间并不包含泛型的类型信息。</p>
<pre><code>ArrayList&lt;String&gt; list = new ArrayList&lt;&gt;();
</code></pre>
<p>编译后等价于：</p>
<pre><code>ArrayList list = new ArrayList();
</code></pre>
<p>只是<strong>编译器偷偷加了强制类型检查和转换</strong>。</p>
<p>因此对于两个问题：</p>
<blockquote>
<p>&lt;font style="color:rgb(51,51,51);"&gt;1、那为什么，&lt;/font&gt;<code>&lt;font style="color:rgb(51,51,51);"&gt;T[] ts = new T[5];&lt;/font&gt;</code>&lt;font style="color:rgb(51,51,51);"&gt; 是不对的，编译的时候，替换为Object，不是相当于：&lt;/font&gt;<code>&lt;font style="color:rgb(51,51,51);"&gt;Object[] ts = new Object[5]&lt;/font&gt;</code>&lt;font style="color:rgb(51,51,51);"&gt;吗？ &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;2、类型擦除，一定是把T变成Object吗？&lt;/font&gt;</p>
</blockquote>
<p>&lt;font style="color:rgb(51,51,51);"&gt;问题 一：&lt;/font&gt;</p>
<blockquote>
<p>首先 java 编译器不允许这样写，原因不是语法，而是在于类型安全。</p>
<p>因为数组是<strong>运行期类型检查</strong>的，数组在运行期知道自己真实的元素类型。</p>
<p>另外，泛型是<strong>编译期类型检查</strong>的，在运行期就已经被删除了，所以 JVM 运行期并不知道元素类型</p>
</blockquote>
<p>举例：</p>
<p>假如<code>&lt;font style="color:rgb(51,51,51);"&gt;T[] ts = new T[5];&lt;/font&gt;</code>&lt;font style="color:rgb(51,51,51);"&gt;允许&lt;/font&gt;</p>
<pre><code>class Box&lt;T&gt; {
    T[] ts = new T[5]; // 假设合法
}

Box&lt;String&gt; box = new Box&lt;&gt;();
Object[] arr = box.ts; // 数组协变，合法
arr[0] = 100;          // 编译通过
</code></pre>
<p>首先 <code>arr</code>是<code>Object[]</code>，放<code>Integer</code>是合理的</p>
<p>但<code>box.ts</code>逻辑上应该是<code>String[]</code>，而且编译器也完全无法阻止这个错误</p>
<p>对于问题二：</p>
<blockquote>
<p>不一定。无边界泛型 -&gt; 擦除成 Object，因为没有任何约束，最安全的上界就是<code>Object</code></p>
<p>如果有上界泛型 -&gt; 擦除成”上界类型“，方法级的泛型同理</p>
</blockquote>
<p>举例如下：</p>
<pre><code>public &lt;T extends Comparable&lt;T&gt;&gt; T max(T a, T b) {
    return a.compareTo(b) &gt; 0 ? a : b;
}
</code></pre>
<p>擦除后：</p>
<pre><code>public Comparable max(Comparable a, Comparable b) {
    return a.compareTo(b) &gt; 0 ? a : b;
}
</code></pre>
<p>另外，对于类型擦除还有许多可以了解的地方</p>
<p>1.<strong>泛型信息不会进入 JVM 的方法签名中，也就是说不存在重载</strong></p>
<p>2.<strong>数组协变+泛型不变</strong></p>
<h3>2.3 泛型的上界</h3>
<p><strong>泛型上界：</strong></p>
<p>限制泛型参数”最多能是什么类型“，并向编译器承诺：他至少具备某个父类或接口的能力。</p>
<p><strong>使用语法</strong>：</p>
<pre><code>class 泛型类名称&lt;类型参数 extends 类型边界&gt; {
}
</code></pre>
<p>这里的<code>extends</code>既<strong>表示继承，也表示实现接口</strong>。</p>
<p>举个例子：</p>
<pre><code>class Box&lt;T extends Number&gt; {
    T value;

    void print() {
        value.intValue();
    }
}
</code></pre>
<p>如果没有<code>extends Number</code>，那么编译器只知道<code>T</code>是<code>Object</code>，<code>Object</code>没有<code>intValue()</code>.</p>
<p>但是加上之后，就相当于上界告诉了编译器：<code>T</code>至少是<code>Number</code>，<code>Number</code>定义了<code>intValue()</code></p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 异常处理：异常体系、传播机制与自定义异常</title>
    <link href="https://blog.syrena.cn/posts/learningexception/learningexception/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/learningexception/learningexception/</id>
    <published>2025-11-17T00:00:00.000Z</published>
    <updated>2025-11-17T00:00:00.000Z</updated>
    <summary>系统讲解 Java 异常机制，从异常体系到 try-catch 实战，结合防御式编程与异常链，掌握高质量错误处理方式.</summary>
    <content type="html"><![CDATA[<h2>1.了解异常</h2>
<h3>1.1 初步认识异常，了解 Exception 家族</h3>
<p>在任何代码中，错误以及意外是不可避免的——除数为零，文件路径不正确，非法输入...既然知道存在这种情况，我们就需要提前预防。</p>
<p>因此 Java 的异常处理机制就应运而生。通过<strong>抛出异常（throw）</strong>、<strong>声明异常（throws）<strong>和</strong>捕获异常（try-catch）</strong>，实现从错误检测到错误恢复的完整流程。</p>
<p>常见异常：</p>
<pre><code>System.out.println(10/0);
//ArithmeticException 算数异常

int[] arr = {1,2,3,4,5};
System.out.println(arr[10]);
//ArrayIndexOutOfBoundsException 数组越界异常

int arr = null;
System.out.println(arr.length);
//NullPointerException 空指针异常...
</code></pre>
<p>事实上，异常又分为<strong>编译时异常（受检异常 <strong>CheckedException</strong>)</strong>，<strong>运行时异常（非受检异常 <strong>UncheckedException</strong>）</strong>。</p>
<p><strong>编译时异常：</strong></p>
<blockquote>
<p>1.编译阶段就会被检查，必须显式处理，否则编译不通过。</p>
<p>2.表示“可预料错误”，如：“文件不存在”、“网络中断”、“数据库连接失败”...</p>
</blockquote>
<p>特点：</p>
<p>编译器要求：要么 <code>try-catch</code> 捕获，要么在方法上 <code>throws</code> 声明</p>
<p><strong>运行时异常：</strong></p>
<blockquote>
<p>1.运行时才可能发生，编译器不会强制要求处理。</p>
<p>2.表示“程序包逻辑错误或无法预料的问题”。</p>
</blockquote>
<p>特点：</p>
<p>通常是开发者的逻辑错误导致的，不建议用 try-catch 去包，而是应修正逻辑。</p>
<p>这里我们先了解下他们的继承关系，如下图</p>
<p><img src="./LearningException1.webp" alt="" /></p>
<p>我们可以了解到：</p>
<p><code>Throwable</code>是异常体系的根，他继承自<code>Object</code>。<code>Throwable</code>有两个体系：<code>Error</code>和<code>Exception</code></p>
<p><code>Error</code><strong>表示严重的错误，程序对此一般无能为力</strong>，错误不是异常，而是脱离程序控制的问题 eg：</p>
<p><code>OutOfMemoryError</code>：内存耗尽</p>
<p><code>NoClassDefFoundError</code>：无法加载某个 Class</p>
<p><code>StackOverflowError</code>：栈溢出</p>
<p><code>Exception</code><strong>则是运行时的错误，它可以被捕获并处理</strong>。</p>
<p>某些异常是应用程序逻辑处理的一部分，应该捕获并处理。eg：</p>
<p><code>NumberFormatException</code>：数值类型的格式错误</p>
<p><code>FileNotFoundException</code>：未找到文件</p>
<p><code>SocketException</code>：读取网络失败</p>
<h2>2. 处理异常</h2>
<h3>2.1 防御式编程</h3>
<p>先了解一下什么是防御式编程，以便让我们更好的了解如何处理异常</p>
<p><strong>防御式编程（Defensive Programming）</strong> 是一种编程思想，指在写代码时<strong>假设“可能出错”</strong>，并提前在程序中<strong>检测、预防、或妥善处理这些潜在错误</strong>，以防止程序在异常情况下崩溃或产生错误结果</p>
<p>简单理解：<br />
👉 就像司机开车时随时准备刹车，而不是等撞上去再想办法</p>
<p>我们可以举一个例子进行对比：</p>
<p><strong>普通写法（无防御）</strong></p>
<pre><code>import java.io.*;

public class Example1 {
    public static void main(String[] args) throws IOException {
        FileReader fr = new FileReader("data.txt");  // 假设文件存在
        BufferedReader br = new BufferedReader(fr);
        System.out.println("第一行内容是：" + br.readLine());
        br.close();
    }
}
</code></pre>
<p><strong>防御式编程</strong></p>
<pre><code>import java.io.*;

public class Example2 {
    public static void main(String[] args) {
        String filePath = "data.txt";
        File file = new File(filePath);

        // ① 防御：检查文件是否存在
        if (!file.exists()) {
            System.err.println("错误：文件不存在 → " + filePath);
            return;
        }

        // ② 防御：检查是否是普通文件（防止是目录）
        if (!file.isFile()) {
            System.err.println("错误：" + filePath + " 不是一个有效文件");
            return;
        }

        // ③ 使用 try-with-resources，自动关闭流
        try (BufferedReader br = new BufferedReader(new FileReader(file))) {
            String firstLine = br.readLine();

            // ④ 防御：检查文件是否为空
            if (firstLine == null) {
                System.err.println("提示：文件为空，没有任何内容。");
                return;
            }

            System.out.println("第一行内容是：" + firstLine);

        } catch (IOException e) {
            // ⑤ 防御：捕获所有IO错误并给出友好提示
            System.err.println("读取文件时出错：" + e.getMessage());
        }
    }
}
</code></pre>
<p>总结一句话就是： 防御式编程就是“<strong>预先假设问题会发生，并设计好应对措施</strong>”，而不是“等异常爆炸再修”。</p>
<p>因此，我们开始踏入 Java 的异常使用啦，先初步了解五个异常常用关键字：</p>
<table>
<thead>
<tr>
<th>关键字</th>
<th>作用</th>
<th>位置</th>
<th>举例</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>throw</code></td>
<td>抛出一个具体的异常对象</td>
<td>方法体内</td>
<td><code>throw new Exception("出错了");</code></td>
</tr>
<tr>
<td><code>throws</code></td>
<td>声明该方法可能抛出哪些异常</td>
<td>方法声明上</td>
<td><code>public void readFile() throws IOException</code></td>
</tr>
<tr>
<td><code>try</code></td>
<td>可能出错的代码块</td>
<td>方法体内</td>
<td><code>try { riskyCode(); }</code></td>
</tr>
<tr>
<td><code>catch</code></td>
<td>捕获并处理异常</td>
<td>紧跟 try 后</td>
<td><code>catch(Exception e) { ... }</code></td>
</tr>
<tr>
<td><code>finally</code></td>
<td>无论是否异常都执行</td>
<td>可选</td>
<td><code>finally { cleanUp(); }</code></td>
</tr>
</tbody>
</table>
<h3>2.2 抛出异常</h3>
<p><strong>追踪本源</strong></p>
<p>当发生错误时，比如用户输入非法字符，那么就可以抛出异常。</p>
<p>抛出异常分为两步，我们可以参考 <code>Integer.parseInt()</code>方法</p>
<p>1.创建<code>Exception</code>的实例</p>
<p>2.用 <code>throw</code>语句抛出</p>
<p>eg:</p>
<pre><code>void example(String s){
    if(s == null){
        NullPointerException e = new NullPointerException();
    throw e;
    //通常写为一条语句，用匿名对象
    //throw new NullPointerException();
    }
}
</code></pre>
<p><strong>但是</strong>，如果一个方法捕获了某个异常后，又在<code>catch</code>子句中抛出新的异常，就相当于把抛出的异常类型“转型”了:</p>
<pre><code>void example1(String s) {
    try {
        example2();
    } catch (NullPointerException e) {
        throw new IllegalArgumentException();
    }
}

void example2(String s) {
    if (s==null) {
        throw new NullPointerException();
    }
}

</code></pre>
<p>这段代码展示的就是将<code>NullPointerException</code>异常转为了<code>IllegalArgumentException</code>，最终展示报错结果只有<code>IllegalArgumentException</code></p>
<p>我们补为 static 方法放在 main 方法中打印出异常栈看看：</p>
<pre><code>public class TestDemo3 {
    public static void main(String[] args) {
        try {
            example1();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    
    static void example1() {
        try {
            example2();
        } catch (NullPointerException e) {
            throw new IllegalArgumentException();
        }
    }

    static void example2() {
        throw new NullPointerException();
    }
}
</code></pre>
<p>打印出的异常栈信息即为：</p>
<pre><code>java.lang.IllegalArgumentException
	at demo2.TestDemo3.example1(TestDemo3.java:23)
	at demo2.TestDemo3.main(TestDemo3.java:13)
</code></pre>
<p>说明了新的异常丢失了原始异常信息，即看不到了原本的<code>NullPointerException</code>的信息了。</p>
<p><strong>所以</strong>，为了追踪完整的异常栈，我们在构造异常的时候就<strong>把原始的</strong><code>Exception</code><strong>实例传进去(<strong>将 e 对象进行</strong>异常包装/异常链)</strong>，新的<code>Exception</code>就可持有原来的<code>Exception</code>信息。代码如下：</p>
<pre><code>public class TestDemo3 {
    public static void main(String[] args) {
        try {
            example1();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void example1() {
        try {
            example2();
        } catch (NullPointerException e) {
            throw new IllegalArgumentException(e);
        }
    }

    static void example2() {
        throw new NullPointerException();
    }
}
</code></pre>
<p>这样我们就保留下来最开始抛出的异常类型了，以方便我们直接溯源根源</p>
<pre><code>java.lang.IllegalArgumentException: java.lang.NullPointerException
	at demo2.TestDemo3.example1(TestDemo3.java:23)
	at demo2.TestDemo3.main(TestDemo3.java:13)
Caused by: java.lang.NullPointerException
	at demo2.TestDemo3.example2(TestDemo3.java:28)
	at demo2.TestDemo3.example1(TestDemo3.java:21)
</code></pre>
<p>在代码中获取原始异常可以使用<code>Throwable.getCause()</code>方法。若返回 null，说明已经是“根异常”了。</p>
<h4>Throwable.getCause()方法使用</h4>
<pre><code>Throwable cause = e.getCause();
</code></pre>
<p>-&gt;返回触发当前异常的“原始异常”</p>
<p>-&gt;如果返回 null，说明当前异常没有再包裹别的异常，是“根异常”。</p>
<p><strong>基本使用</strong>：</p>
<pre><code>try {
    someMethod();
} catch (Exception e) {
    Throwable cause = e.getCause();
    System.out.println("当前异常是：" + e);
    System.out.println("原始原因是：" + cause);
}
</code></pre>
<p><strong>实际开发中常用：遍历整条异常链</strong></p>
<p>若异常被包装多次，可以使用循环一直调用 getCause()；</p>
<pre><code>static void printChain(Throwable t) {
    while (t != null) {
        System.out.println("异常: " + t);
        t = t.getCause();  // ⭐关键：向上查找
    }
}
</code></pre>
<h3>2.3 捕获异常</h3>
<h4>2.3.1 异常声明 throws</h4>
<p>throws 处在方法声明参数列表之后，当方法中抛出编译时异常，用户不想处理该异常，此时就可以借助 throws 将异常抛出给方法的调用者来处理。<strong>即当前方法不处理异常，提醒方法的调用者处理异常。</strong></p>
<pre><code>修饰词 返回值类型 方法名(参数列表) throws 异常类型1,异常类型2...{}
</code></pre>
<p>我们举个例子看看 throws 用法</p>
<pre><code>public void OpenConfig(String filename) throws FileNotFoundException{
    if(filename.equals("config.ini")){
        throw new FileNotFountException("配置文件名字不对");
    }
} 
</code></pre>
<p><strong>语法点注意</strong>：</p>
<blockquote>
<p>1.throws 必须更在方法的参数列表之后</p>
<p>2.声明的异常必须是 Exception 或者 Exception 的子类</p>
<p>3.方法内部如果抛出多个异常，throws 之后必须跟多个异常类型，之间用逗号隔开，如果抛出多个异常型具有父子关系，直接声明父类即可。</p>
<p>4.调用声明抛出异常的方法时，调用者必须对该异常进行处理，或者继续使用 throws 抛出</p>
</blockquote>
<h4>2.3.2 try-catch 捕获并处理</h4>
<p>throws 对异常并没有真正处理，而是将异常报告给抛出异常方法的调用者，由调用者处理。如果真正要对异常进行处理，就需要 try-catch。</p>
<pre><code>try{
// 将可能出现异常的代码放在这里
    } catch(要捕获的异常类型 e){
// 如果try中的代码抛出异常了，此处catch捕获时异常类型与try中抛出的异常类型一致时，或者是try中抛出异常的基类
//时，就会被捕获到
// 对异常就可以正常处理，处理完成后，跳出try-catch结构，继续执行后序代码
    } catch(异常类型 e){
// 对异常进行处理
    } finally{
// 此处代码一定会被执行到
        }
</code></pre>
<blockquote>
<p>1.try 块内抛出异常位置，<strong>之后的代码将不会被执行</strong></p>
<p>2.如果抛出异常类型与 catch 时异常类型不匹配，即异常不会被成功捕获，也就不会被处理，继续往外抛，知道 JVM 收到后中断程序----异常是按照类型来捕获的</p>
<p>3.try 中可能会抛出多个不同的异常对象，则必须使用多个 catch 来捕获----多种异常，多次捕获</p>
</blockquote>
<h4>2.3.3finally</h4>
<p>像在代码中，当程序需要中断并且需要我们关闭资源避免浪费的时候，finally 很好的帮我们解决了这个问题。</p>
<pre><code>语法格式：
        try{
// 可能会发生异常的代码
        }catch(异常类型 e){
// 对捕获到的异常进行处理
        }finally{
// 此处的语句无论是否发生异常，都会被执行到
        }
// 如果没有抛出异常，或者异常被捕获处理了，这里的代码也会执行
</code></pre>
<p>举个范例：</p>
<pre><code>public class TestFinally {
    public static int getData(){
        Scanner sc = null;
        try{
            sc = new Scanner(System.in);
            int data = sc.nextInt();
            return data;
        }catch (InputMismatchException e){
            e.printStackTrace();
        }finally {
            System.out.println("finally中代码");
        }
        System.out.println("try-catch-finally之后代码");
        if(null != sc){
            sc.close();
        }
        return 0;
    }
    public static void main(String[] args) {
        int data = getData();
        System.out.println(data);
    }
}
// 正常输入时程序运行结果：
100
finally中代码
100
</code></pre>
<p>上述程序，如果正常输入，成功接收输入后程序就返回了，try-catch-finally 之后的代码根本就没有被释放，造成资源泄漏。</p>
<p><strong>注意</strong>：</p>
<blockquote>
<p>finally 中的代码一定会执行的，一般在 finally 中进行一些资源清理的扫尾工作。</p>
</blockquote>
<pre><code>// 下面程序输出什么？
public static void main(String[] args) {
    System.out.println(func());
}
public static int func() {
    try {
        return 10;
    } finally {
        return 20;
    }
}
A: 10 B: 20 C: 30 D: 编译失败
</code></pre>
<p>答案解析：</p>
<blockquote>
<p>java 规则：</p>
<p>1.try 中遇到 return 时，先把返回值记录下来，但不立即返回</p>
<p>2.finally 会在 return 执行前运行</p>
<p>3.如果 finally 中也写了 return，他会覆盖 try 的返回值</p>
</blockquote>
<p>因此建议：不在 finally 中写 return（被编译器当作一个警告）</p>
<p>【面试题】：</p>
<ol>
<li>
<p>throw 和 throws 的区别？</p>
</li>
<li>
<p>finally中的语句一定会执行吗？</p>
</li>
</ol>
<blockquote>
<ol>
<li>throws 用于方法声明处，表示该方法可能抛出某类异常，让调用者处理。<br />
throw 用于方法内部，创建并抛出一个异常对象，中断方法执行。<br />
throws 是声明，throw 是动作；throws 后跟异常类型，throw 后跟异常对象。</li>
</ol>
<p>2.几乎一定会执行，但有两个例外！</p>
<p>JVM 提前退出，eg: 调用 <code>System.exit(0);</code> JVM 直接停止，不执行 finally</p>
<p>finally 执行前被线程杀死，eg: (1)线程再 finally 前被 stop() (2)程序崩溃导致线程终止</p>
</blockquote>
<h3>2.4 异常的处理流程</h3>
<p>我们先了解一下“调用栈”</p>
<blockquote>
<p>方法之间是存在互相调用关系的，这种调用关系我们可以用“调用栈”来描述，在 JVM 中有一块内存空间称为“虚拟机栈”专门存储方法之间的调用关系，当代码中出现异常的时候，我们就可以使用<code>e.printStackTrace();</code>的方式查看出现异常代码的调用栈</p>
</blockquote>
<blockquote>
<ul>
<li>你（main 方法）→ 去办业务</li>
<li>办事人员让你先去窗口 A（调用 methodA）</li>
<li>窗口 A 又让你先去窗口 B（调用 methodB）</li>
<li>窗口 B 又让你去窗口 C（调用 methodC）</li>
</ul>
<p><strong>你去每一个窗口，都会把“你当前在哪一层”记录下来。</strong></p>
<p>这就是——<strong>调用栈（Call Stack）</strong></p>
</blockquote>
<p>然后呢，了解调用栈之后我们能更好的理解异常处理流程了</p>
<p>1.代码运行时发生错误 -&gt; JVM 创建异常对象</p>
<p>2.JVM 将异常抛出到当前方法（throw）</p>
<p>3.JVM 查找匹配的 catch（当前方法内查找）</p>
<p>4.当前方法没有处理 -&gt; 异常向上抛给调用者（throws）</p>
<p>5.异常层层向上，知道被某层 catch 处理</p>
<p>6.如果最终无人处理 -&gt; JVM 答应异常栈 -&gt; 程序终止</p>
<p>如果有匹配的 catch -&gt; catch 执行</p>
<p>7.finally 总会执行</p>
<p>8.若 catch 中再次 throw -&gt; 异常类型被“转换”继续向上抛</p>
<h2>3.自定义异常类</h2>
<p>当我们在代码中需要抛出异常时，尽量使用 JDK 已定义的异常类型。例如，参数检查不合法，应该抛出<code>IllegalArgumentException</code>：</p>
<pre><code>static void example1(int age){
    if(age &lt;= 0){
        throw new IllegalArgumentException();
    }
}
</code></pre>
<p>常见做法是自定义一个 <code>BaseException</code>作为“根异常”，然后，派生出各种业务类型的异常。</p>
<p><code>BaseException</code>需要从一个适合的<code>Exception</code>派生，通常建议从<code>Runtime Exception</code>派生：</p>
<pre><code>public class BaseException extends RuntimeException {
}
</code></pre>
<p>也因此，其他业务类型的异常就可以从<code>BaseException</code>派生：</p>
<pre><code>public class UserNotFoundException extends BaseException {
}

public class LoginFailedException extends BaseException {
}

...
</code></pre>
<p>自定义的<code>BaseException</code>应该提供多个构造方法：</p>
<pre><code>public class BaseException extends RuntimeException {
    public BaseException() {
        super();
    }

    public BaseException(String message, Throwable cause) {
        super(message, cause);
    }

    public BaseException(String message) {
        super(message);
    }

    public BaseException(Throwable cause) {
        super(cause);
    }
}
</code></pre>
<p>注意事项：</p>
<ul>
<li>自定义异常通常会继承自 Exception 或者 RuntimeException</li>
<li>继承自 Exception 的异常默认是受查异常</li>
<li>继承自 RuntimeException 的异常默认是非受查异常</li>
</ul>
<p>还需注意受查异常和非受查异常的应用区别</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java String 深入理解：不可变性、常量池与高效拼接</title>
    <link href="https://blog.syrena.cn/posts/stringclass/stringclass/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/stringclass/stringclass/</id>
    <published>2025-10-29T00:00:00.000Z</published>
    <updated>2025-10-29T00:00:00.000Z</updated>
    <summary>重识String类，运用String类.</summary>
    <content type="html"><![CDATA[<h2>1.字符串构造</h2>
<h3>1.1 再识 String</h3>
<p>String 类常用构造方式：</p>
<pre><code>String s1 = "Hello Sirens";					//直接创建
System.out.println(s1);

String s2 = new String("Hello Sirens");		//对象创建
System.out.println(s1);

char[] array = {'S','i','r','e','n','s'};
String s3 = new String(array);				//对象创建
System.out.println(s3);
</code></pre>
<p><strong>注意</strong>：</p>
<blockquote>
<p>1.String 是引用类型，内部并不存储字符串，而是像静态方法一样，通过引用去调用堆区的 value（JDK9 之后 value 由 char[] 类型改为了 byte[] 类型，）</p>
</blockquote>
<p><img src="./StringClass1.png" alt="" /></p>
<h3>1.2 String 直接创建与对象创建</h3>
<pre><code>String s1 = "Hello";
String s2 = "Sirens";
String s3 = s1;
String s4 = new String("Hello");
String s5 = new String("World");
</code></pre>
<p>以上为 String <code>直接创建</code>和<code>对象创建</code>内存示意图（画起来真累</p>
<p><img src="./StringClass2.png" alt="" /></p>
<p>整个过程：</p>
<blockquote>
<p>1️⃣ <code>String s1 = "Hello";</code></p>
<ul>
<li>JVM 检查字符串常量池：是否已有 <code>"Hello"</code>？</li>
<li>如果没有，就在堆里创建一个 <strong>String对象#1001</strong>，内容为 <code>"Hello"</code>；</li>
<li>常量池记录一条映射 <code>"Hello" → #1001</code>；</li>
<li>局部变量表中的 <code>s1</code> 存放引用 <code>#1001</code>。</li>
</ul>
<p>2️⃣ <code>String s2 = "Sirens";</code></p>
<ul>
<li>常量池查找 <code>"Sirens"</code>，没有；</li>
<li>在堆中创建一个新的 String 对象 <strong>#1002</strong>；</li>
<li>常量池记录 <code>"Sirens" → #1002</code>；</li>
<li>s2 引用 #1002。</li>
</ul>
<p>3️⃣ <code>String s3 = s1;</code></p>
<ul>
<li>这是<strong>引用赋值</strong>，不是新建。</li>
<li>s3 和 s1 引用完全相同的对象。</li>
</ul>
<p>4️⃣ <code>String s4 = new String("Hello");</code></p>
<ul>
<li><code>"Hello"</code> 是常量池字面量，已存在，指向堆中 #1001；</li>
<li><code>new String("Hello")</code> 表示：
<ol>
<li>读取常量池引用 #1001；</li>
<li>在堆中再创建一个新的 String 对象 #2001；</li>
<li>把 #1001 的字符数组内容复制到 #2001；</li>
<li>s4 引用的是 #2001。</li>
</ol>
</li>
</ul>
<p>5️⃣ <code>String s5 = new String("World");</code></p>
<ul>
<li>常量池中查找 <code>"World"</code>，发现没有；</li>
<li>在堆中创建 <code>"World"</code> 的常量池条目 <code>"World" → #1003</code>；</li>
<li><code>new String("World")</code> 又在堆中创建一个新的对象 #2002；</li>
<li>s5 引用 #2002。</li>
</ul>
</blockquote>
<h3>1.3 编译器优化</h3>
<p><strong>重点补充</strong>：这是我在学校的课上遇到的代码，说到这我就想提提这个</p>
<pre><code>String hello = "你好";
String testOne = "你" + "好";    // [代码1]
System.out.println(hello == testOne); //输出结果是 true
System.out.println("你好" == testOne); //输出结果是 true
System.out.println("你好" == hello); //输出结果是 true
String you = "你";
String hi = "好";
String testTwo = you + hi;    // [代码2]
System.out.println(hello == testTwo); //输出结果是 false
String testThree = you + hi;
System.out.println(testTwo == testThree); //输出结果是 false
</code></pre>
<p>首先我们要知道：</p>
<blockquote>
<p>常量+常量=常量</p>
<p>常量+变量=变量</p>
<p>变量+变量=变量</p>
</blockquote>
<p>我们分成三部分分析：</p>
<p><strong>部分一：</strong></p>
<pre><code>String hello = "你好";
String testOne = "你" + "好";		//编译器优化
</code></pre>
<p>在编译时，Java 编译器发现 <code>"你"</code> 和 <code>"好"</code> 都是<strong>常量字符串</strong>，于是它直接在编译阶段就做了优化：</p>
<pre><code>String testOne = "你好";
</code></pre>
<p>也就是说——</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>存放内容</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>hello</code></td>
<td>指向字符串常量池中 <code>"你好"</code></td>
</tr>
<tr>
<td><code>testOne</code></td>
<td>也指向同一个常量池中的 <code>"你好"</code></td>
</tr>
</tbody>
</table>
<p><strong>因为字符串常量池中，"你好" 只有一份副本</strong>。</p>
<p>所以：</p>
<pre><code>hello == testOne   // true
"你好" == testOne   // true
"你好" == hello     // true
</code></pre>
<p>它们的“==”都在比较<strong>同一个引用地址（常量池中同一个对象）</strong>。</p>
<hr />
<p><strong>部分二</strong>：</p>
<pre><code>String you = "你";
String hi = "好";
String testTwo = you + hi;
</code></pre>
<p>这里的关键在于：</p>
<blockquote>
<p><code>you</code> 和 <code>hi</code> 是<strong>变量</strong>，不是编译时的常量。</p>
<p>所以，编译器<strong>无法在编译期确定 you + hi 的值</strong>。<br />
只能在<strong>运行时</strong>由 <code>StringBuilder</code> 拼接生成新字符串对象。</p>
</blockquote>
<p>编译器其实会把上面的代码翻译成：</p>
<pre><code>String testTwo = new StringBuilder()
                    .append(you)
                    .append(hi)
                    .toString();
</code></pre>
<p>这时候：</p>
<blockquote>
<p><code>"你"</code> 和 <code>"好"</code> 各自仍然在<strong>常量池</strong>中；</p>
<p>但拼接产生的 <code>"你好"</code> 是在<strong>堆</strong>里新创建的对象；</p>
<p>它没有放回常量池。</p>
</blockquote>
<p>于是：</p>
<pre><code>hello == testTwo   // false
</code></pre>
<p>因为两者引用的地址不同：</p>
<blockquote>
<p><code>hello</code> → 常量池</p>
<p><code>testTwo</code> → 堆中新对象</p>
</blockquote>
<hr />
<p><strong>部分三</strong>：</p>
<p>为什么 testTwo 和 testThree 也不相等</p>
<pre><code>String testThree = you + hi;
System.out.println(testTwo == testThree); // false
</code></pre>
<p>理由同上，每次拼接：</p>
<blockquote>
<p>都会通过 <code>StringBuilder</code> 重新 new 出一个新的字符串对象；</p>
<p>虽然内容相同，但内存地址不同。</p>
</blockquote>
<p>因此：</p>
<pre><code>testTwo == testThree   // false
</code></pre>
<p>除非你手动调用 <code>.intern()</code>：</p>
<pre><code>testTwo.intern() == testThree.intern()   // true
</code></pre>
<p><code>intern()</code> 会把字符串放进常量池里，如果常量池已有相同内容的，就直接返回那份引用。</p>
<h2>2.String 对象直接的比较</h2>
<p>1.<code>==</code>比较所引用的是否同属一个地址</p>
<p>我们知道，当<code>==</code>两边都为基本数据类型变量时，比较的是两值之间是否相等而不是地址。</p>
<p>但 String 不一样，首先 String 不是基本数据类型，而是引用数据类型，这在我们的 Java SE 基础提及过。</p>
<pre><code>String s1 = new String("hello");
String s2 = new String("hello");
String s3 = new String("world");
String s4 = s1;
System.out.println(s1 == s2); // false
System.out.println(s2 == s3); // false
System.out.println(s1 == s4); // true
</code></pre>
<p>2.<code>boolean equals(Object anObject)</code>方法去按照字典顺序比较</p>
<p>字典序--字符大小的顺序（类比 ascii）</p>
<p>方法库中 String 类本身重写了父类 Object 中的 equals 方法，String 类重写后 equals 方法由比较地址改为了如下规则</p>
<p><img src="./StringClass3.png" alt="" /></p>
<pre><code>System.out.println(s1.equals(s2)); // true
System.out.println(s1.equals(s3)); // false
</code></pre>
<p>3.<code>int compareTo(String s)</code>方法去按照字典序进行比较</p>
<p>虽然与 equals 同为按照字典序比较，但其返回值不同。equals 返回 boolean 类型，而 compareTo 返回的是 int 类型。</p>
<p>这也和我们之前的文章谈 compareTo 的时候提到过，<code>(s1.compareTo(s2)</code>如果 s1 比 s2 大，那么返回正数；反之。</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;但也有要点要注意：&lt;/font&gt;</p>
<pre><code>String s1 = "abcd";
String s2 = "abe";
String s3 = "ab";
String s4 = "abcd";

System.out.println(s1.compareTo(s2));   //此时都为第三位不同，仅比较到第三位
System.out.println(s3.compareTo(s4));   //此时仅比较长度，因为前面都相同
</code></pre>
<p>4.<code>int compareToIgnoreCase(String str)</code>方法，与 compareTo 方式相同，但忽略大小写比较</p>
<pre><code>String s1 = new String("abc");
String s2 = new String("ac");
String s3 = new String("ABc");
String s4 = new String("abcdef");
System.out.println(s1.compareToIgnoreCase(s2)); // 不同输出字符差值-1
System.out.println(s1.compareToIgnoreCase(s3)); // 相同输出 0
System.out.println(s1.compareToIgnoreCase(s4)); // 前k个字符完全相同，输出长度差值 -3
</code></pre>
<h2>3.字符串查找</h2>
<p>查找，我们用到的思想就是索引了，String 类提供的常用查找的方法：</p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>功能</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>char charAt(int index)</td>
<td>返回 index 位置上字符，如果 index 为负数或者越界，抛出 IndexOutOfBoundsException 异常</td>
</tr>
<tr>
<td>int indexOf(int ch)</td>
<td>返回 ch 第一次出现的位置，没有返回 -1</td>
</tr>
<tr>
<td>int indexOf(int ch,int fromIndex)</td>
<td>从 fromindex 位置开始找 ch 第一次出现的位置，没有返回-1</td>
</tr>
<tr>
<td>int indexOf(String str,int fromIndex)</td>
<td>返回 str 第一次出现的位置，没有返回 -1</td>
</tr>
<tr>
<td>int lastIndexOf(int ch)</td>
<td>从 fromindex 位置开始找 str 第一次出现的位置，没有返回-1</td>
</tr>
<tr>
<td>int lastIndexOf(int ch,int fromIndex)</td>
<td>从后往前找，返回 ch 第一次出现的位置，没有返回-1</td>
</tr>
<tr>
<td>int lastIndexOf(int ch,int fromIndex)</td>
<td>从 fromIndex 位置开始找，从后往前找 ch 第一次出现的位置，没有返回-1</td>
</tr>
<tr>
<td>int lastIndexOf(String str)</td>
<td>从后往前找，返回 str 第一次出现的位置，没有返回-1</td>
</tr>
<tr>
<td>int lastIndexOf(String str,int fromIndex)</td>
<td>从 fromIndex 位置开始找，从后往前找 str 第一次出现的位置，没有返回-1</td>
</tr>
</tbody>
</table>
<pre><code>public static void main(String[] args) {
        String s1 = "Hello SSiirrieeSns";	
        //S  S  i  i  r  r  i  e  e  S  n  s
        //0  1  2  3  4  5  6  7  8  9  10 11
        System.out.println(s1.charAt(2));				//l
        System.out.println(s1.indexOf("r"));			//10
        System.out.println(s1.indexOf("l",3));			//3
        System.out.println(s1.lastIndexOf("S"));		//15
        System.out.println(s1.lastIndexOf("S",13));		//7
        System.out.println(s1.lastIndexOf(" ",13));		//5
    }
</code></pre>
<p>以上所调用到的方法产生的 String 类型结果都是方法中 <code>return new String</code>；</p>
<h2>4.String 方法</h2>
<h3>4.1 转换</h3>
<p><strong>数值和字符串转换</strong></p>
<pre><code>String s1 = String.valueOf(1234);
String s2 = String.valueOf(12.34);
String s3 = String.valueOf(true);
String s4 = String.valueOf(new Student("Sirens", 19));
</code></pre>
<p>同样其他类型也能转为 String 类型</p>
<pre><code>int data1 = Integer.parseInt("1234");
double data2 = Double.parseDouble("12.34");
//Integer、Double等是Java中的包装类型
</code></pre>
<table>
<thead>
<tr>
<th><strong>方法名</strong></th>
<th><strong>描述</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>String toUpperCase()</td>
<td>小写转大写</td>
</tr>
<tr>
<td>String toLowerCase()</td>
<td>大写转小写</td>
</tr>
<tr>
<td>String toCharArray()</td>
<td>字符串转数组</td>
</tr>
<tr>
<td>format(格式,数值)</td>
<td>格式化</td>
</tr>
</tbody>
</table>
<h3>4.2 字符串调整</h3>
<p><strong>替换</strong></p>
<table>
<thead>
<tr>
<th><strong>方法名</strong></th>
<th><strong>功能</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>String replaceAll(String regex,String replace)</td>
<td>替换所有的指定内容&lt;br/&gt;(匹配此字符串的正则表达式,用来替换每个匹配项的字符串)</td>
</tr>
<tr>
<td>String replaceFirst(String regex,String replacement)</td>
<td>替换首个内容&lt;br/&gt;(匹配此字符串的正则表达式,&lt;font style="color:rgb(51, 51, 51);background-color:rgb(250, 252, 253);"&gt;用来替换第一个匹配项的字符串)&lt;/font&gt;</td>
</tr>
</tbody>
</table>
<p>注意:</p>
<blockquote>
<p>由于字符串是不可变对象, 替换不修改当前字符串, 而是产生一个新的字符串.</p>
</blockquote>
<p><strong>拆分</strong></p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>功能</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>String[] split(String regex)</td>
<td>将字符串全部拆分&lt;br/&gt;(正则表达式分隔符)</td>
</tr>
<tr>
<td>String[] split(String regex,int limit)</td>
<td>将字符串以指定的格式，拆分为 limit 组&lt;br/&gt;(正则表达式分隔符,分割的份数)</td>
</tr>
</tbody>
</table>
<p><strong>注意</strong>：</p>
<blockquote>
<p>像拆分 IP 地址，有的需要转义字符</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;1. 字符"|","*","+"都得加上转义字符，前面加上 "\" . &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;2. &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;而如果是 &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;"" &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;，那么就得写成 &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;"\\" &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;. &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;3. 如果一个字符串中有多个分隔符，可以用"|"作为连字符.&lt;/font&gt;</p>
</blockquote>
<pre><code>String site = "https://sirens007.github.io";
String[] s = site.split("\\.|://");
for (int i = 0; i &lt; s.length; i++) {
    System.out.print(s[i]+" ");
}
</code></pre>
<p><strong>拆分</strong></p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>描述</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>String[] substring(int beginIndex)</td>
<td>从指定索引截取到结尾</td>
</tr>
<tr>
<td>String[] substring(int beginIndex, int endIndex)</td>
<td>截取部分内容</td>
</tr>
</tbody>
</table>
<p><strong>注意</strong>：</p>
<blockquote>
<p>1.索引是从 0 开始的</p>
<p>2.Java 中默认都是左闭右开区间，所以如果截取部分内容的话 [beginIndex, endIndex),不取右值</p>
</blockquote>
<hr />
<p><strong>补充方法</strong>：</p>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>描述</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>boolean contains(CharSequence chars)</td>
<td>contains() 方法用于判断字符串中是否包含指定的字符或字符串&lt;br/&gt;chars -- 要判断的字符或字符串</td>
</tr>
<tr>
<td>String trim()</td>
<td>返回字符串的副本，忽略前导空白和尾部空白</td>
</tr>
</tbody>
</table>
<p>使用案例：</p>
<pre><code> String Str = new String("    sirens007.gtihub.io    ");
 System.out.print("原始值 :" );
 System.out.println( Str );

 System.out.print("删除头尾空白 :" );
 System.out.println( Str.trim() );

//执行结果
//原始值 :    sirens007.github.io        
//删除头尾空白 :sirens007.github.io
</code></pre>
<h2>5.字符串的不可变性</h2>
<p><strong>一、String 是一种不可变对象，字符串中的内容是不可改变的。</strong></p>
<p><img src="./StringClass4.png" alt="" />为了求证官方文件，我们打开 jdk 源码可以看到该核心定义</p>
<blockquote>
<ul>
<li><code>class String</code>首先被声明为<code>final</code>，表示不能被继承</li>
<li>其次<code>value</code>数组被声明为<code>private final</code>--表明 value 自身的值不能改变，即不能引用其它字符数组，但是其引用空间中的内容可以修改。</li>
</ul>
</blockquote>
<p>这说明了以内容：</p>
<blockquote>
<p>1.外部无法访问；</p>
<p>2.初始化后不可再被修改引用；</p>
<p>3.数组内容虽可变，但外部没有访问入口。</p>
</blockquote>
<p>以上说明意味着：</p>
<p>String 对象一旦创建，就固定绑定了内部<code>value[]</code>;</p>
<p>修改字符串（如<code>"abc".replace("a","x")</code>）时，实际上是新创建了一个 String 对象。</p>
<p><strong>举例：</strong></p>
<p>可能很多人认为是 final 修饰了 value 导致 value 不能改变，其实不然</p>
<p>那我们就举一个例子找找是谁限制住了不可修改，<strong>如下</strong></p>
<p><img src="./StringClass5.png" alt="" /></p>
<p>我们在 Test5 文件 <code>default final</code> 定义了数组 str，使用 Test3 去访问修改，可知编译器没有报错</p>
<p><img src="./StringClass6.png" alt="" /></p>
<p>当我们修改为了 private 后</p>
<p><img src="./StringClass7.png" alt="" /></p>
<p><img src="./StringClass8.png" alt="" /></p>
<p>显然爆红了，于此我们得到验证应该是 private 限制了你去访问。</p>
<p>二、所有涉及到可能修改字符串内容的操作都是创建一个新对象，改变的是新对象</p>
<p><img src="./StringClass9.png" alt="" /></p>
<p>我们就拿 String 类里的 replace 方法，可以看到的是只有”结果不变“的情况才返回<code>this</code></p>
<p>即：当字符串内容没有发生任何变化时，直接返回原对象。</p>
<h2>6. 字符串修改</h2>
<p>我们要尽量避免直接对 String 类型对象进行修改，因为 String 类是不能修改的，所有的修改都会创建新对象，效率很低下，就比如下面这种变量拼接（常量拼接没有问题，零开销）</p>
<pre><code>String s1 = "hello";
String s2 = " world";
String s3 = s1 + s2;
</code></pre>
<p>此时编译器会在字节码层面生成：</p>
<pre><code>new StringBuilder()
    .append(s1)
    .append(s2)
    .toString();
//也就是下面这样
StringBuilder stringbuilder = new StringBuilder();
stringbuilder.append("hello");
stringbuilder.append("world");
String ret = stringbuilder.toString();
System.out.println(ret);
</code></pre>
<p>所以在运行时：</p>
<blockquote>
<p>创建一个新的 <code>StringBuilder</code>；</p>
<p>两次 <code>append()</code>；</p>
<p>再调用 <code>toString()</code> 创建新字符串。</p>
</blockquote>
<p>这就是为什么 <code>"hello" + " world"</code> 和 <code>s1 + s2</code>虽然语法一样，但性能差距巨大。</p>
<p>我们还可以用代码来粗略比较一下 StringBuffer 和 StringBuilder</p>
<pre><code>public static void main(String[] args) {
        long start = System.currentTimeMillis();
        String s = "";
        for(int i = 0; i &lt; 10000; ++i){
            s += i;
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
        start = System.currentTimeMillis();
        StringBuffer sbf = new StringBuffer("");
        for(int i = 0; i &lt; 10000; ++i){
            sbf.append(i);
        }
        end = System.currentTimeMillis();
        System.out.println(end - start);
        start = System.currentTimeMillis();
        StringBuilder sbd = new StringBuilder();
        for(int i = 0; i &lt; 10000; ++i){
            sbd.append(i);
        }
        end = System.currentTimeMillis();
        System.out.println(end - start);
    }
</code></pre>
<p>得到的结果依此是 70 、1、1，可以看出在对 String 进行修改时，效率是非常慢的。因此，如果要修改的话我们尽量使用 StringBuffer 或者 StringBuilder</p>
<h2>7.StringBuilder 和 StringBuffer</h2>
<h3>7.1 二者区别</h3>
<p>当对字符串进行修改的时候，需要使用 StringBuffer 和 StringBuilder 类。</p>
<p>和 String 类不同的是，StringBuffer 和 StringBuilder 类的对象能够被多次的修改，并且不产生新的未使用对象。</p>
<p><img src="./StringClass10.png" alt="" /></p>
<p><strong>StringBuilder</strong></p>
<pre><code>public final class StringBuilder extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {

    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }
}
</code></pre>
<p><strong>StringBuffer</strong></p>
<pre><code>public final class StringBuffer extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {

    public synchronized StringBuffer append(String str) {
        super.append(str);
        return this;
    }
}
</code></pre>
<p>唯一的区别就在于：</p>
<blockquote>
<p><code>StringBuffer</code> 的方法前多了一个 <code>synchronized</code> 关键字。</p>
</blockquote>
<p><strong>StringBuffer</strong>：多线程情况下</p>
<blockquote>
<p>StringBuffer 类比公共厕所，</p>
<p>每次只能进去一人；</p>
<p>门口有锁（<code>synchronized</code>同步锁），别人必须等前一个人出来才能进去。</p>
<p>确保每次使用都“安全”，不会出现混乱（数据不一致）</p>
</blockquote>
<p>你可以这么理解：</p>
<blockquote>
<p>假如你和同事都要上厕所（多个线程操作字符串），<br />
<code>StringBuffer</code> 就像门上了锁的公共厕所：<br />
一个用完另一个再用，虽然慢一点，但不会出事故。</p>
</blockquote>
<p><strong>StringBuilder</strong>：单线程情况下</p>
<blockquote>
<p>没有锁，谁抢到谁用</p>
<p>设计时假设“你家就你一个人”，所以没必要加锁</p>
</blockquote>
<p>你也可以这么理解：</p>
<blockquote>
<p>如果你自己住（单线程程序），<br />
当然不用给厕所门上锁，加锁反而浪费时间。<br />
所以 <code>StringBuilder</code> 就是单人卫生间，速度飞快，但只适合自己用。</p>
</blockquote>
<h3>7.2 StringBuilder 和 StringBuffer</h3>
<table>
<thead>
<tr>
<th><strong>方法</strong></th>
<th><strong>说明</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>StringBuffer append(String str)</code></td>
<td>在尾部追加，相当于 <code>String</code> 的 <code>+=</code>，可以追加：<code>boolean</code>、<code>char</code>、<code>char[]</code>、<code>double</code>、<code>float</code>、<code>int</code>、<code>long</code>、<code>Object</code>、<code>String</code>、<code>StringBuffer</code>变量</td>
</tr>
<tr>
<td><code>char charAt(int index)</code></td>
<td>获取 <code>index</code>位置的字符</td>
</tr>
<tr>
<td><code>int length()</code></td>
<td>获取字符串的长度</td>
</tr>
<tr>
<td><code>int capacity()</code></td>
<td>获取底层保存字符串空间的大小</td>
</tr>
<tr>
<td><code>void ensureCapacity(int minimumCapacity)</code></td>
<td>扩容</td>
</tr>
<tr>
<td><code>void setCharAt(int index, char ch)</code></td>
<td>将 <code>index</code>位置的字符设置为 <code>ch</code></td>
</tr>
<tr>
<td><code>int indexOf(String str)</code></td>
<td>返回 <code>str</code>第一次出现的位置</td>
</tr>
<tr>
<td><code>int indexOf(String str, int fromIndex)</code></td>
<td>从 <code>fromIndex</code>位置开始找 <code>str</code>第一次出现的位置</td>
</tr>
<tr>
<td><code>int lastIndexOf(String str)</code></td>
<td>返回最后一次出现 <code>str</code>的位置</td>
</tr>
<tr>
<td><code>int lastIndexOf(String str, int fromIndex)</code></td>
<td>从 <code>fromIndex</code>位置开始找 <code>str</code>最后一次出现的位置</td>
</tr>
<tr>
<td><code>StringBuffer insert(int offset, String str)</code></td>
<td>在 <code>offset</code>位置插入各种基本类型、<code>String</code>类型或 <code>Object</code>类型数据</td>
</tr>
<tr>
<td><code>StringBuffer deleteCharAt(int index)</code></td>
<td>删除 <code>index</code>位置的字符</td>
</tr>
<tr>
<td><code>StringBuffer delete(int start, int end)</code></td>
<td>删除 <code>[start, end)</code>区间内的字符</td>
</tr>
<tr>
<td><code>StringBuffer replace(int start, int end, String str)</code></td>
<td>将 <code>[start, end)</code>区间的字符替换为 <code>str</code></td>
</tr>
<tr>
<td><code>String substring(int start)</code></td>
<td>从 <code>start</code>开始一直到末尾的子串以 <code>String</code>方式返回</td>
</tr>
<tr>
<td><code>String substring(int start, int end)</code></td>
<td>将 <code>[start, end)</code>范围内的子串以 <code>String</code>方式返回</td>
</tr>
<tr>
<td><code>StringBuffer reverse()</code></td>
<td>反转字符串</td>
</tr>
<tr>
<td><code>String toString()</code></td>
<td>将所有字符按 <code>String</code>的方式返回</td>
</tr>
</tbody>
</table>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 抽象类与接口：类型约束、接口继承与 Object 基类</title>
    <link href="https://blog.syrena.cn/posts/abstractclass-interface/abstractclass-interface/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/abstractclass-interface/abstractclass-interface/</id>
    <published>2025-10-17T00:00:00.000Z</published>
    <updated>2025-10-17T00:00:00.000Z</updated>
    <summary>深度了解抽象类、接口的应用，补充对Object的了解.</summary>
    <content type="html"><![CDATA[<h2>1.抽象类</h2>
<h3>1.1啥是抽象类？</h3>
<p>在面向对象的概念中，所有的对象都是通过类来描绘的，但是反过来，并不是所有的类都是用来描绘对象的，如果一个类中没有足够的信息来描绘一个具体的对象，这样的类就是抽象类。</p>
<p><img src="./abstractclass-interface1.png" alt="" /></p>
<p>像这样的Animal类一般只为服务创建Dog类和Cat类的，我们就可以直接定义为抽象类<code>（abstract class）</code>，以及animal类的<code>bark()</code>方法我们通常不调用，所以我们同样可以写为抽象方法<code>（abstract function）</code>。</p>
<p>像这样子的抽象类和抽象方法就是为了让子类去重写再描述具体对象而产生的。</p>
<p>并且我们同样合理的利用编译器的语法规则去校验我们的代码，以防错误的创建和访问。</p>
<h3>1.2抽象类的特性</h3>
<blockquote>
<p>注意：</p>
<p>1.抽象类不能直接实例化对象，但其他功能和普通类仍然一样</p>
<p>2.抽象类必须被继承</p>
<p>3.抽象方法不能是private的</p>
<p>4.抽象方法不能被final和static修饰，因为抽象方法要被子类重写</p>
<p>5.抽象类必须被继承，并且继承后子类要<strong>重写父类中的抽象方法</strong>，否则子类也是抽象类，必须使用<code>abstract</code>修饰</p>
<p>6.抽象类中不一定包含抽象方法，但是有抽象方法的类一定是抽象类</p>
<p>7.抽象类中可以有构造方法，供子类创建对象时，初始化父类的成员变量</p>
</blockquote>
<h3>1.3如何使用抽象类呢？</h3>
<p>我们依旧拿Animal举例哈（问就是想象力匮乏...</p>
<pre><code>public abstract class Animal {
    public abstract void bark();
}

//是在不同文件哈
public class Dog extends Animal
    //记得要重写抽象方法哈
    public void bark(){
        System.out.println("在汪汪汪");
    }
}
</code></pre>
<h3>1.4抽象类的作用</h3>
<blockquote>
<p>经过上面的学习，我们可以知道抽象类就是为了给定子类一个大的框架，保证后面的子类根据抽象类做更多定义。</p>
<p>记住哈，抽象类本身不能被实例化，要想使用，只能创建该抽象类的子类，然后让子类重写抽象类中的抽象方法。</p>
<p>另外呢，我想说，我们使用抽象类的意义就是为了多一重编译器的校验，尽管普通类就足够，但既然有这样的语法，我们就应该按照规范去完成啦😊</p>
</blockquote>
<h2>2.接口</h2>
<h3>2.1接口的声明</h3>
<pre><code>public interface [接口名称]{
    public void method();
}
</code></pre>
<p>注意:</p>
<blockquote>
<p>1.创建接口时，接口的命名一般以大写字母<code>I</code>开头</p>
<p>2.接口的命名一般使用“形容词”词性的单词</p>
<p>3.接口是隐式抽象的，当声明一个接口的时候，不必使用<strong>abstract</strong>关键字</p>
<p>4.接口中的方法都是共有的</p>
</blockquote>
<h3>2.2接口的使用</h3>
<p>接口必须要通过一个“实现类”来实现该接口，并且要实现接口中的<strong>所有方法</strong>；否则，类必须声明为抽象的类。</p>
<p>eg:(这是两个文件</p>
<pre><code>public interface Iaction{
    void eat();
    void walk();
    void sleep();
    ...
}

public class Dog implements Iaction{
    @Override
    public void eat() {}
    @Override
    public void walk() {}
    @Override
    public void sleep() {}
}
</code></pre>
<blockquote>
<p>1.子类与父类之间是extends继承关系，类与接口之间是implements实现关系</p>
<p>2.这两种关系可以同时作用</p>
<p>3.继承不能多继承，但一个类可以同时实现多个接口</p>
</blockquote>
<h3>2.3接口的特性</h3>
<p>1.接口当中的成员变量，默认是<code>public static final</code>修饰的（只能是public，用private会编译报错）</p>
<p>2.接口当中的抽象方法，默认都是<code>public abstract</code>修饰的（只能是噢、public abstract，其他修饰符都会报错）</p>
<p>3.如果接口当中的方法被<code>default</code>修饰，那么可以有具体的实现</p>
<pre><code>default void test(){
System.out.println("default method");
</code></pre>
<p>4.如果接口当中的方法被<code>static</code>修饰，那么可以有具体的实现</p>
<pre><code>static void test(){
System.out.println("static method");
</code></pre>
<p>5.接口是不可以进行实例化的</p>
<p>6.类和接口之间可以使用关键字<code>implements</code>来实现接口</p>
<p>7.一个接口对应一个字节码文件</p>
<p>8.接口中不能有静态代码块和构造方法</p>
<p>9.如果类中没有实现接口中的所有方法，则类必须设置为抽象类</p>
<h3>2.4实现多个接口</h3>
<p>实现接口不像继承类一样，它可以多个实现，比如下面</p>
<pre><code>public class Animal implements Iwalk,Iswim,Ieat{}
</code></pre>
<p>注意：</p>
<p>通常我们用抽象类或父类实现接口的时候，所实现的接口一定要有共性，如walk，eat一些动物都会的...</p>
<p>另外，一个类实现多个接口时，每个接口中的抽象方法都要实现，否则类必须设置为抽象类。</p>
<blockquote>
<p>提示，IDEA中使用 ctrl + i 可以快速实现接口</p>
</blockquote>
<p>因此我们可以这样去理解，继承表达的含义是<code>is-a</code>语义，而接口表达的含义是<code>具有xxx</code>特性</p>
<blockquote>
<p>猫是一种动物，具有会跑的特性</p>
<p>青蛙也是一种动物，既能跑，也能游泳</p>
</blockquote>
<p>这样的话，我们可以不用父类实现所有接口，只需要每一个动物类去单独实现该有的特性，然后在运行类这样写，再在main方法中像这样去调用即可</p>
<pre><code>public class Test {
    public static void walk(IWalk iwalk){
        iwalk.walk();
    }
    public static void main(String[] args) {
        Dog dog = new Dog("PaPy Dog",3);
        Test.walk(dog);
        Test.walk(new Dog("dog",2));	//匿名对象同样也行
    }
}
</code></pre>
<p>但是如果该具体动物类没有实现对应接口，那么就会报错</p>
<p><img src="./abstractclass-interface2.png" alt="" /></p>
<h3>2.5接口间的继承</h3>
<pre><code>interface IA{
    public void ia();
}
interface IB{
    public void ib();
}
interface IC extends IA,IB{
    public void ic();
}
</code></pre>
<p>但我们去实现接口IC的时候，要重写IC所继承的所有方法，以及它本身的方法。</p>
<p>即 要重写ia，ib，ic三个方法</p>
<blockquote>
<p>接口间的继承相当于把多个接口合并在一起</p>
</blockquote>
<h2>3.Object类</h2>
<h3>3.1初步了解Object类</h3>
<p><img src="./abstractclass-interface3.png" alt="" /></p>
<p>首先，我们要知道Object类是Java默认提供的一个类，Java里除了Object类，所有的类都是存在继承关系的。默认继承Object父类。即所有类的对象都可以使用Object的引用进攻接收。</p>
<p>eg:我们用Object接受所有类的对象</p>
<pre><code>class Dog{}
class Cat{}

public class Test {
    public static void main(String[] args) {
        Object obj1 = new Dog("dog",2);
        Object obj2 = new Cat("cat",2);
    }
}
</code></pre>
<p>也就是说都可以向上转型</p>
<p>所以在开发中，Object类是参数的最高统一类型。但Object类也有定义好的一些方法。我们主要关注hashCode(),equal(),toString()这几个方法即可</p>
<h3>3.2获取对象信息</h3>
<p>打印对象信息，我们可以直接重写或直接调用Object类的<code>toString方法</code></p>
<pre><code>// Object类中的toString()方法实现：
public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

//重写的toString
@Override
    public String toString() {
        return "Dog{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
    Object obj = new Dog("dog",2);
    System.out.println(obj.toString());  //这样调用即可
</code></pre>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 接口实践：对象比较、克隆与 equals/hashCode 契约</title>
    <link href="https://blog.syrena.cn/posts/interface-thefuncofobject/interface-thefuncofobject/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/interface-thefuncofobject/interface-thefuncofobject/</id>
    <published>2025-10-17T00:00:00.000Z</published>
    <updated>2025-10-17T00:00:00.000Z</updated>
    <summary>拓展接口在实际中应用的例子.</summary>
    <content type="html"><![CDATA[<h3>接口使用实例以及Object类中的equals,hashcode方法</h3>
<h2>1.Comparable 接口实现</h2>
<p>实现该接口时需要加上一个泛型（类比 C++的模板）</p>
<p><code>Class Student implements Comparable&lt;Student&gt;</code>,我们比较什么类型就填什么类型在&lt;&gt;</p>
<p>我们了解了 Comparable 接口之后，会知道里面存在一个方法 compareTo()</p>
<p>因此 compareTo()需要我们去重写，这其实就是一个<strong>比较规则</strong>，需要怎么比较类型可以由我们来完成，我们举出一个例子如下</p>
<pre><code>class Student implements Comparable&lt;Student&gt;{
    public String name;
    public int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public int compareTo(Student o) {
        //通过年龄比较
        return this.age - o.age;
        //通过名字比较
        //return this.name.compareTo(o.name);
    }
}

public class Test {
    public static void main(String[] args) {
        Student std1 = new Student("小李",20);
        Student std2 = new Student("小刘",19);
        System.out.println(std1.compareTo(std2));
    }
}

//运行结果为 1
</code></pre>
<p>有人会好奇，为什么用名字比较会这么写 <code>return this.name.compareTo(o.name);</code></p>
<p><img src="./Interface-theFuncOfObject1.png" alt="" /></p>
<p>可以发现，String 类型中也有 compareTo()方法，因此我们直接调用即可</p>
<p>当我们将<strong>用到数组对对象进行排序</strong>时，中间的对比过程正是运用了类似这个步骤</p>
<pre><code>class Student /*implements Comparable&lt;Student&gt;*/{
    public String name;
    public int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString(){
        return "Student {name=" + name + ", age=" + age + "}";
    }
    /*@Override
    public int compareTo(Student o) {
        return this.age - o.age;
        //return this.name.compareTo(o.name);
    }*/
}

public class Test {
    public static void main(String[] args) {
        Student std[] = new Student[3];
        std[0] = new Student("小李",20);
        std[1] = new Student("小刘",19);
        std[2] = new Student("小新",18);
        Arrays.sort(std);
        System.out.println(Arrays.toString(std));
    }
}
</code></pre>
<p>我们继续使用上面那个例子，将 <code>compareTo()</code>方法以及接口屏蔽 会发现运行到<code>Arrays.sort(std);</code>这里的时候发生异常</p>
<p><img src="./Interface-theFuncOfObject2.png" alt="" /></p>
<p><strong>ClassCastException</strong> 报错信息中 为类型转换异常</p>
<p><code>class demo3.Student cannot be cast to class java.lang.Comparable</code>也就是说 Student 不能被转换为 Comparable 型，</p>
<p><img src="./Interface-theFuncOfObject3.png" alt="" /></p>
<p>图片中的意思是：</p>
<blockquote>
<p><strong>这是报错信息中的一个方法，Object[] a 是我们传 Student std[] 的形参</strong></p>
<p><strong>这是原本正确运行需要进行的，但是 我们现在连 Comparable 接口都还没有实现，怎么能强转为 Comparable 类型呢，更不用说调用 compareTo()方法了</strong></p>
<p><strong>因此当我们放开 Comparable 实现和 compareTo()方法重写，那么 Arrays.sort()就会按照我们是按名字还是按年龄写的 compareTo()方法去排序</strong>。实际上也就是把<strong>比较规则</strong>放出来，别人才能拿来比较</p>
</blockquote>
<p>那么我们得到的结论就是：</p>
<blockquote>
<p><strong>只要是自定义的类型，涉及到了大小比较，那就得一定实现 Comparable 接口</strong></p>
</blockquote>
<p>至于 <code>Arrays.sort()</code>内部是怎样实现的，我给出一个<strong>大概的模拟实现</strong>，实际内部更复杂</p>
<p>类似冒泡排序的排序方法</p>
<pre><code>public static void mySort(Comparable[] comparables){
    for (int i = 0; i &lt; comparables.length-1; i++) {
        for (int j = 0; j &lt; comparables.length-1-i; j++) {
            if(comparables[j].compareTo(comparables[j+1]) &gt; 0) {
                Comparable tmp = comparables[j];
                comparables[j] = comparables[j+1];
                comparables[j+1] = tmp;
            }
        }
    }
}
</code></pre>
<p>为什么要用 <code>Comparable[] comparables</code> 作为形参呢，因为就是我们前面说的<strong>向上转型</strong>一样转为 <code>Comparable 类型</code>，我们就可以直接使用这个接口中的方法。</p>
<p>再整体看一下这段代码</p>
<pre><code>class Student implements Comparable&lt;Student&gt;{
    public String name;
    public int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString(){
        return "Student {name=" + name + ", age=" + age + "}";
    }
    @Override
    public int compareTo(Student o) {
        //方法二：将this和o调换位置也可以变为降序
        return this.age - o.age;
        //return this.name.compareTo(o.name);
    }
}

public class Test1 {
    public static void mySort(Comparable[] comparables){
        for (int i = 0; i &lt; comparables.length-1; i++) {
            for (int j = 0; j &lt; comparables.length-1-i; j++) {
                //方法一：这里大于零做的是一个升序的排序，改为小于零就是降序了
                if(comparables[j].compareTo(comparables[j+1]) &gt; 0) {
                    //swap(comparables, j, j+1);
                    Comparable tmp = comparables[j];
                    comparables[j] = comparables[j+1];
                    comparables[j+1] = tmp;
                }
            }
        }
    }

    // public static &lt;T&gt; void swap(T[] arr, int i, int j) {
    //     T tmp = arr[i];
    //     arr[i] = arr[j];
    //     arr[j] = tmp;
    // }


    public static void main(String[] args) {
        Student std[] = new Student[3];
        std[0] = new Student("小李",20);
        std[1] = new Student("小刘",19);
        std[2] = new Student("小新",18);
        mySort(std);
        System.out.println(Arrays.toString(std));
        //System.out.println(std1.compareTo(std2));
    }
}
</code></pre>
<p>但是这个接口的方法存在一定的局限性，就是我有时候想用年龄去排可以，但我有时候又想用名字去排，但这个方法不能共存，只能一个 <strong>compareTo()</strong> 方法（比较器）（/也就是比较规则）</p>
<p><strong>补充建议</strong>：</p>
<p>其实像上面提到的用年龄比较</p>
<pre><code>    @Override
    public int compareTo(Student o) {
        //通过年龄比较
        return this.age - o.age;
    }
</code></pre>
<p>我们实际上还可以用它类型自带的 <code>compare()</code>方法，下面我们举一个例子</p>
<ul>
<li>让 <code>compareTo()</code> 按 <strong>score 从高到低排序</strong>；</li>
<li>如果分数相同，则按 <strong>年龄从小到大排序</strong>。</li>
</ul>
<pre><code>    @Override
    public int compareTo(Student o) {
        int cmp = Double.compare(o.score,this.score);
        if(cmp == 0){
            cmp = Integer.compare(this.age,o.age);
        }
        return cmp;
    }
</code></pre>
<p>这里我们就用到了整型自带的<code>Integer.compare()</code>方法和 double 型自带的<code>Double.compare()</code>方法；为什么建议用类型自带的方法呢而不用以下这种</p>
<pre><code>@Override
public int compareTo(Student o) {
    if (this.score &lt; o.score) {
        return 1;              // 分数低的排在后面
    } else if (this.score &gt; o.score) {
        return -1;             // 分数高的排在前面
    } else {
        return o.age - this.age; // 分数相同时，年龄大的排前
    }
}

</code></pre>
<p><strong>问题：compareTo 的返回值逻辑不够直观</strong></p>
<p>🔹 那样写更安全（避免浮点数误差问题）<br />
🔹 逻辑清晰，可读性高<br />
🔹 不会因为 <code>==</code> 比较浮点数出问题</p>
<h2>2.Comparator 接口实现</h2>
<p>我们可以创建一个新的类 <code>NameComparator</code></p>
<pre><code>import java.util.Comparator;

public class NameComparator implements Comparator&lt;Student&gt;{
    @Override
    public int compare(Student o1,Student o2){
        return o1.name.compareTo(o2.name);
    }
}
</code></pre>
<p>以及另外一个新的类 <code>AgeComparator</code></p>
<pre><code>import java.util.Comparator;

public class AgeComparator implements Comparator&lt;Student&gt; {
    @Override
    public int compare(Student o1, Student o2) {
        //按照降序排
        return o1.age - o2.age;
    }
}
</code></pre>
<p>这个方法不需要依赖 Student类 去实现任何接口</p>
<pre><code>AgeComparator ageComparator = new AgeComparator();
System.out.println(ageComparator.compare(std[0],std[1]));
</code></pre>
<p>直接实例化调用，如果需要升序降序我们可以再去分类写几个升序降序的类即可；即我们可以同时存在多个类似地比较方法，<strong>只需要放在不同类去实现接口重写 compare()方法就行！！！</strong></p>
<h2>3.Clonable 接口和深拷贝</h2>
<p>当我们先了解到下面要讲的 Object 类之后可以再回头看看</p>
<p>Object 类中有一个 clone 方法，这个方法的作用就是字面意思，克隆一个对象</p>
<p>举一个例子：</p>
<pre><code>public class Person implements Cloneable{
    public int age;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    @Override
    public String toString() {
        return "Person{" +
                "age=" + age +
                '}';
    }
}
</code></pre>
<p>使用步骤：</p>
<blockquote>
<ol>
<li>创建 Person 类，然后实例化一个 person1，接着再克隆给对象 person2</li>
</ol>
<p>2.重写 Object 父类的 clone() 方法（因为该方法在 Object 中是 protected 访问权） [Alt+Ins 键 选择重写克隆 clone()方法即可]</p>
<p>4.注意返回值是父类 Object，所以我们需要向下转型为 Person</p>
<p>5.在 Person类 中实现 Cloneable 接口</p>
<p>6.抛去异常处理<code>public static void main(String[] args) throws CloneNotSupportedException </code></p>
</blockquote>
<p>补充一下，Cloneable 是空接口，空接口也叫标记接口，表示当前类可以被克隆</p>
<pre><code>public static void main(String[] args) throws CloneNotSupportedException {
        Person person1 = new Person();
        person1.age = 19;
        Person person2 = (Person)person1.clone();
        System.out.println(person2.toString());
    }
</code></pre>
<p>我们再拓展一下，新增一个 Money 类</p>
<pre><code>class Money{
    public double money = 19.9;
}

public class Person implements Cloneable{
    public int age;
    public String name;

    public Person(int age,String name){
        this.age=age;
        this.name=name;
    }

    Money m = new Money();

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

    @Override
    public String toString() {
        return "Person{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", money=" + m.money +
                '}';
    }
}
</code></pre>
<p>此时在栈和堆中是这样的，引用同一个 m 对象，我们因此称之为<strong>浅拷贝</strong><img src="./Interface-theFuncOfObject4.png" alt="" /></p>
<p>如果 m 对象也被拷贝了，那就是深拷贝</p>
<p>所以我们就进行如下操作变为<strong>深拷贝</strong>吧</p>
<ol>
<li>先让 Money 能够被克隆</li>
</ol>
<pre><code>class Money implements Cloneable{
    public double money = 19.9;

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
</code></pre>
<ol>
<li>然后再修改一下克隆方法</li>
</ol>
<pre><code>protected Object clone() throws CloneNotSupportedException {
    Person tmp = (Person)super.clone();
    tmp.m = (Money)m.clone();
    return tmp;
}
</code></pre>
<p>我们再进行测试一下即可</p>
<pre><code>public static void main(String[] args) throws CloneNotSupportedException {
        Person person1 = new Person(19,"小李");
        person1.age = 19;
        Person person2 = (Person)person1.clone();

        System.out.println("修改前："+person1.toString());
        System.out.println("修改前："+person2.toString());
        person2.m.money = 100;
        System.out.println("修改后："+person1.toString());
        System.out.println("修改后："+person2.toString());
    }
</code></pre>
<p>返回的结果：</p>
<blockquote>
<p>修改前：Person{age=19, name='小李', money=19.9}</p>
<p>修改前：Person{age=19, name='小李', money=19.9}</p>
<p>修改后：Person{age=19, name='小李', money=19.9}</p>
<p>修改后：Person{age=19, name='小李', money=100.0}</p>
</blockquote>
<p>此时的 Person2 的 m 对象是另外一个独立的对象，与 Person 引用的 m 不是同一个</p>
<h2>4.对象比较equals方法</h2>
<p>&lt;font style="color:rgb(51,51,51);"&gt;在Java中，==进行比较时： &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;a.&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;如果&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;==&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;左右两侧是基本类型变量，比较的是变量中值是否相同 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;b.&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;如果&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;==&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;左右两侧是引用类型变量，比较的是引用变量地址是否相同 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;c.如果要比较对象中内容，必须重写Object中的equals方法，因为equals方法默认也是按照地址比较的：&lt;/font&gt;</p>
<pre><code>// Object类中的equals方法
public boolean equals(Object obj) {
    return (this == obj); // 使用引用中的地址直接来进行比较
}
</code></pre>
<p>我们自己写一个比较引用类型中的基本类型比较(eg:Person类中的name和age)</p>
<pre><code>public boolean equals(Object obj) {
    if (obj == null) {
        return false ;
    }
    if(this == obj) {
        return true ;
    }
// 不是Person类对象
    if (!(obj instanceof Person)) {
        return false ;
    }
    Person person = (Person) obj ; // 向下转型，比较属性值
    return this.name.equals(person.name) &amp;&amp; this.age==person.age ;
}
</code></pre>
<p>结论：比较对象中内容是否相等的时候，一定要重写equals方法。</p>
<h2>5.hashcode方法</h2>
<p>我们回忆一下Object中的toString方法</p>
<pre><code>// Object类中的toString()方法实现：
public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;我们看到了hashCode()这个方法，他帮我算了一个具体的&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;对象位置&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;，这里面涉及数据结构，但是我们还没学数据结构，没法讲述，所以我们只能说它是个内存地址。然后调用Integer.toHexString()方法，将这个地址以16进制输出。&lt;/font&gt;</p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;hashcode方法源码&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;：&lt;/font&gt;</p>
<pre><code>public native int hashCode();
</code></pre>
<p>该方法是一个native方法，底层是由C/C++编写，无法观察到</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;我们认为两个名字相同，年龄相同的对象，将存储在同一个位置，如果不重写hashcode()方法，我们可以来看示例代码：&lt;/font&gt;</p>
<pre><code>class Person {
    public String name;
    public int age;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
public class TestDemo4 {
    public static void main(String[] args) {
        Person per1 = new Person("Sirens", 20) ;
        Person per2 = new Person("Sirens", 20) ;
        System.out.println(per1.hashCode());
        System.out.println(per2.hashCode());
    }
}
//执行结果
460141958
1163157884
</code></pre>
<p>注意事项：两个对象的hash值不一样。</p>
<p>像重写equals方法一样，我们也可以重写hashcode()方法。此时我们再来看看。</p>
<pre><code>class Person {
    public String name;
    public int age;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}
public class TestDemo4 {
    public static void main(String[] args) {
        Person per1 = new Person("Sirens", 20) ;
        Person per2 = new Person("Sirens", 20) ;
        System.out.println(per1.hashCode());
        System.out.println(per2.hashCode());
    }
}
//执行结果
460141958 
460141958
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;注意事项：哈希值一样。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;结论： &lt;/font&gt;</p>
<blockquote>
<p>&lt;font style="color:rgb(119,119,119);"&gt;1、hashcode方法用来确定对象在内存中存储的位置是否相同 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(119,119,119);"&gt;2、事实上hashCode() 在散列表中才有用，在其它情况下没用。在散列表中hashCode() 的作用是获取对象的散列码，进而确定该对象在散列表中的位置。&lt;/font&gt;</p>
</blockquote>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 继承机制：成员访问、构造链与组合选择</title>
    <link href="https://blog.syrena.cn/posts/inheritance/inheritance/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/inheritance/inheritance/</id>
    <published>2025-09-27T00:00:00.000Z</published>
    <updated>2025-09-27T00:00:00.000Z</updated>
    <summary>深度解析，详细了解Java继承</summary>
    <content type="html"><![CDATA[<h2>1.继承</h2>
<h3>1.1类的继承</h3>
<h4>1.1.1 继承格式</h4>
<p>Java 中通过 <code>extends</code>关键字可以声明一个类是从另外一个类继承而来的，形式如下：</p>
<pre><code>class 父类 {
}

class 子类 extends 父类 {
}
</code></pre>
<p>继承的目的是为了降低代码的重复，避免代码量大且臃肿。通过提取出代码的共性，实现代码的复用。</p>
<p><strong>要注意：</strong></p>
<p><strong>1.子类会将父类中的成员变量或者成员方法继承到子类中</strong></p>
<p><strong>2.子类继承父类后，要添加自己特有成员，体现出与父类不同，否则没必要经行继承。</strong></p>
<h4>1.1.1 extends关键字</h4>
<p>在 Java 中，类的继承是单一继承的，也就是说，<strong>一个子类只能拥有一个父类，所以 extends 只能继承一个类</strong>。</p>
<h3>1.2继承类型</h3>
<p>注意的是 java 不支持多继承，但支持多重继承。</p>
<p><img src="./inheritance1.png" alt="" /></p>
<p>要知道，事实上我们并不需要过多的继承关系，这会导致向上追踪过于复杂。如果需要继承层次太多，就需要考虑对代码进行重构了。<strong>一般我们不希望超过三层的继承关系。</strong></p>
<p>如果想从语法上进行限制继承，就可以使用 <strong>&lt;font style="color:#DF2A3F;"&gt;final &lt;/font&gt;关键字</strong> 了</p>
<h3>1.3继承的特性</h3>
<ul>
<li>子类拥有父类非private 的属性、方法</li>
<li>子类可以拥有自己的属性和方法，即子类可以对父类进行扩展。</li>
<li>子类可以用自己的方式实现父类的方法</li>
<li>Java 的继承是单继承，但是可以多重继承，单继承就是一个子类只能继承一个父类，多重继承就是，例如 B 类继承 A 类，C 类继承 B 类，所以按照关系就是 B 类是 C 类的父类，A 类是 B 类的父类，这是 Java 继承区别于 C++ 继承的一个特性。</li>
<li>提高了类之间的耦合性（继承的缺点，耦合度高就会造成代码之间的联系越紧密，代码独立性越差）。</li>
</ul>
<h3>1.4子类访问父类</h3>
<h4>1.4.1 子类访问父类成员变量</h4>
<p>通常子类在继承父类之后，父类的成员变量就会依附在子类中。但是如果<strong>子类重新定义了与父类同名的成员变量</strong>时，那么访问该变量时，会<strong>优先访问子类的变量</strong>。</p>
<p>eg:</p>
<pre><code>class TestA {
    int a = 1;
    int b = 2;
}
class TestB extends TestA{
    int a = 1111;
}

//实例化 TestB b 
//此时System.out.println(b.a);得到的结果会是 1111
</code></pre>
<p>总结：<strong>成员变量访问遵循就近原则，自己有优先自己的，如果没有则向父类中找</strong>。</p>
<h4>1.4.2 子类访问父类的成员方法</h4>
<p>与访问成员变量，如果名字相同以及参数相同，那么会优先访问子类的。</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;通过派生类对象访问父类与子类同名方法时，如果父类&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;和子类同名方法的参数列表不同(重载)&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;，根据调用 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;方法适传递的参数选择合适的方法访问，如果没有则报错；&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;因此引出了一个问题，如果我偏偏想要访问父类的方法呢？ 那么就需要我们学习下面这个关键字了(&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;super&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;)&lt;/font&gt;</p>
<h3>&lt;font style="color:rgb(51,51,51);"&gt;1.5 super 与 this 关键字&lt;/font&gt;</h3>
<h4>1.5.1 super &amp; this</h4>
<p>该关键字主要作用：<strong>在子类方法中访问父类的成员</strong>。</p>
<p><strong>super 关键字</strong>：我们可以通过 super 关键字来实现对父类成员的访问，用来引用当前对象的父类。</p>
<p><strong>this关键字</strong>：指向自己的引用，引用当前对象，即它所在的方法或构造函数所属的对象实例。</p>
<pre><code>class Animal {
    void eat() {
        System.out.println("animal : eat");
    }
}
 
class Dog extends Animal {
    void eat() {
        System.out.println("dog : eat");
    }
    void eatTest() {
        this.eat();   // this 调用自己的方法
        super.eat();  // super 调用父类方法
    }
}
 
public class Test {
    public static void main(String[] args) {
        Animal a = new Animal();
        a.eat();
        Dog d = new Dog();
        d.eatTest();
    }
}
</code></pre>
<p>输出结果为：</p>
<pre><code>animal : eat
dog : eat
animal : eat
</code></pre>
<p><img src="./inheritance2.png" alt="" /></p>
<p>​									<strong>类以及变量都存放如图位置（jdk8之后）</strong></p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;注意：&lt;/font&gt;</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;1. &lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;只能在非静态方法中使用 &lt;/font&gt;</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;2. &lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;在子类方法中，访问父类的成员变量和方法&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。&lt;/font&gt;</p>
<h4>1.5.2 final 关键字</h4>
<p>final 可以用来修饰变量 （包括类属性、对象属性、局部变量和形参）、方法（包括类方法和对象方法）和类。</p>
<p>使用final 关键字声明类，就是把类定义为最终类，不能被继承，或者用于修饰方法，该方法不能被子类重写（密封类）：</p>
<ul>
<li>声明类：</li>
</ul>
<pre><code>final class 类名 {}
</code></pre>
<ul>
<li>声明方法：</li>
</ul>
<pre><code>修饰符(public/private/default/protected) final 返回值类型 方法名(){//方法体}
</code></pre>
<p><strong>注意：</strong></p>
<p><strong>1.final 定义的类，其中的属性、方法不是final 的</strong>。</p>
<h3>1.6 子类构造方法</h3>
<p>&lt;font style="color:rgb(51,51,51);"&gt;父子父子，先有父再有子，即：子类对象构造时，需要先调用基类构造方法，然后执行子类的构造方法。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;注意：是在父类显示构造方法时。子类需要去调用。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;父子父子肯定是先有父再有子，所以在构造子类对象时候 ，&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;先要调用基类的构造方法&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;，将从基类继承下来的成员构造完整，然后&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;再调用子类自己的构造方法&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;，将子类自己新增加的成员初始化完整。&lt;/font&gt;</p>
<pre><code>class Base{
    int age;
    String name;
    double length;

    public Base(int age, String name) {
        this.age = age;
        this.name  = name;
        System.out.println("Base constructor called");
    }
}

public class Derived extends Base {
    public Derived(int age, String name) {
        super(1,"1");
    }
}

public class Main {
    public static void main(String[] args) {
        Derived d = new Derived(1,"1");
    }
}

//输出结果为：Base constructor called
</code></pre>
<p>注意：</p>
<p>1.若父类显示定义无参或者默认的构造方法，在子类构造方法第一行默认由隐含的super()调用，即调用基类构造方法</p>
<p>2.如果父类构造方法是带有参数的，此时需要用户为子类显式定义构造方法，并在子类构造方法中选择合适的父类构造方法调用，否则编译失败。</p>
<p>3.在子类构造方法中，super(...)只能在子类构造方法中出现一次，并且不能和this同时出现</p>
<h3>1.7 再谈初始化</h3>
<h4>1.7.1 代码块的执行顺序</h4>
<pre><code>class Base {
    public String name;
    public int age;

    public Base(String name,int age){
        this.name=name;
        this.age=age;
        System.out.println("Base构造方法");
    }
    {
        System.out.println("Base实例代码块");
    }
    static{
        System.out.println("Base静态代码块");
    }
}

class Derived extends Base {
    public Derived(){
        super("Derived",20);
        System.out.println("Derived构造方法");
    }
    {
        System.out.println("Derived实例代码块");
    }
    static{
        System.out.println("Derived静态代码块");
    }
}

public class Main{
    public static void main(String[] args) {
        Derived d = new Derived();
        System.out.println("==============");
        Derived e = new Derived();
    }
}

//输出结果：
Base静态代码块
Derived静态代码块
Base实例代码块
Base构造方法
Derived实例代码块
Derived构造方法
==============
Base实例代码块
Base构造方法
Derived实例代码块
Derived构造方法
</code></pre>
<p>我们可以得到该结论：</p>
<p>1、父类静态代码块优先于子类静态代码块执行，且是最早执行的</p>
<p>2、<strong>父类实例代码块和父类构造方法</strong>紧接着执行</p>
<p>3、<strong>子类的实例代码块和子类构造方法</strong>紧接着在执行</p>
<p>4、第二次实例化子对象时，父类和子类的静态代码块都将不会再执行</p>
<h4>1.7.1 构造方法的执行顺序</h4>
<h5>顺序一：</h5>
<pre><code>class Y {
    public Y() {
        System.out.print("Y");
    }
}

class X {
    Y y = new Y();  // 成员变量的初始化
    public X() {
        System.out.print("X");
    }
}

public class Main {
    public static void main(String[] args) {
        X x = new X();
    }
}

//执行结果：YX
</code></pre>
<p>🔑<strong>Java 对象的初始化顺序(记住：先成员，后构造）</strong></p>
<p>在 Java 中，当你 <code>new X()</code> 时，发生的步骤是：</p>
<ol>
<li><strong>分配内存</strong><br />
JVM 为 <code>X</code> 对象分配空间。</li>
<li><strong>初始化成员变量（包括显式赋值）</strong><br />
在执行构造方法体之前，&lt;font style="color:#DF2A3F;"&gt;先对所有 实例成员变量（包括 &lt;/font&gt;<code>new Y()</code>&lt;font style="color:#DF2A3F;"&gt;）进行初始化&lt;/font&gt;。<br />
所以会先执行 <code>Y</code> 的构造方法，打印 <code>"Y"</code>。</li>
<li><strong>执行构造方法体</strong><br />
然后才会进入 <code>X</code> 的构造方法，打印 <code>"X"</code>。</li>
</ol>
<h5>顺序二：</h5>
<pre><code>class Y{
    public Y(){
        System.out.print("Y");
    }
}
class X extends Y{
    Y y = new Y();
    public X(){
        System.out.print("X");
    }
}
public class Test {
    public static void main(String[] args) {
        X x = new X();
    }
}

//执行结果：YYX
</code></pre>
<p>执行顺序是：</p>
<ol>
<li>因为是继承父类，所以先调用父类构造</li>
<li>其次才是子类字段<code>Y y = new Y();</code>可看作是实例代码块执行顺序</li>
<li>最后才是子类自己的构造方法</li>
</ol>
<h5>顺序三：</h5>
<pre><code>class X {
    Y y = new Y(); // (1)
    public X() {   // (2)
        System.out.print("X");
    }
}

class Y {
    public Y() {   // (3)
        System.out.print("Y");
    }
}

public class Z extends X {
    Y y = new Y(); // (4)
    public Z() {   // (5)
        System.out.print("Z");
    }
    public static void main(String[] args) {
        new Z();
    }
}

//执行结果：YXYZ
</code></pre>
<p>执行<code>new Z();</code>顺序说明：</p>
<ol>
<li>执行 <code>X</code> 里的 <code>Y y = new Y(); // (1)</code>， 调用 <code>Y()</code> 构造器</li>
<li>👉 进入 <code>X()</code> 构造器</li>
<li>执行 <code>Z</code> 里的 <code>Y y = new Y(); // (4)</code>， 调用 <code>Y()</code> 构造器</li>
<li>👉 进入 <code>Z()</code> 构造器</li>
</ol>
<p>调用说明：</p>
<ol>
<li>JVM 给 <code>Z</code> 对象分配内存，成员变量先设为默认值（此时 <code>X.y = null</code>, <code>Z.y = null</code>）。</li>
<li><code>Z</code> extends <code>X</code>，所以先去构造 <code>X</code>。   但在调用 <code>X()</code> 构造方法体前，必须先初始化 <code>X</code> 的成员变量。</li>
</ol>
<p><strong>总结： “父变量 → 父构造 → 子变量 → 子构造”</strong></p>
<h3>1.8protected关键字</h3>
<p>主要限制：类或类中成员能否在类外或者其他包中被访问。</p>
<p><img src="./inheritance3.png" alt="" /></p>
<p>注意：父类中private成员变量虽然在子类中不能直接访问，但是也继承到子类中了</p>
<h3>1.9 继承与组合</h3>
<p>组合，组合并没有涉及到特殊的语法，仅仅是将一个类的实例作为另一个类的字段。</p>
<p>继承表示对象间is-a的关系：狗是动物，猫是动物</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;组合表示对象间has-a的关系：汽车（包含轮子，车座...）&lt;/font&gt;</p>
<pre><code>class Tire{
    ...
}
class Engine{
    ..
}
class VehicleSystem{
    ...
}

class Car{
    private Tire tire;
    private Engine engine;
    private VehicleSystem vs;
    ...
}

class Benz extends Car{
    ...
}
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;一般建议：能用组合尽量用组合。&lt;/font&gt;</p>
<p>以上是我关于Java继承的笔记分享，</p>
<p>感谢你读到这里，这也是我学习路上的一个小小记录。希望以后回头看时，能看到自己的成长~</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 多态机制：类型转换、方法重写与动态绑定</title>
    <link href="https://blog.syrena.cn/posts/polymorphism/polymorphism/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/polymorphism/polymorphism/</id>
    <published>2025-09-27T00:00:00.000Z</published>
    <updated>2025-09-27T00:00:00.000Z</updated>
    <summary>用实例讲解多态思想</summary>
    <content type="html"><![CDATA[<h2>&lt;font style="color:rgb(51,51,51);"&gt;1.多态&lt;/font&gt;</h2>
<p><strong>对一个事物产生不同的态度</strong></p>
<h3>1.1多态实现条件</h3>
<p><strong>多态实现的三个必要条件</strong></p>
<ul>
<li>必须在父类的继承体系下</li>
<li>子类必须要对父类中方法进行重写</li>
<li>通过父类的引用调用重写的方法</li>
</ul>
<p>当使用多态方式调用方法时，首先检查父类中是否有该方法，如果没有，则编译错误。<strong>如果有，再去调用子类的同名方法</strong>。</p>
<h3>1.2向上转型和向下转型</h3>
<h4>1.2.1向上转型</h4>
<p>概念：创建一个子类对象，将其当成父类对象来使用。</p>
<p>语法格式：父类类型 对象名 = new 子类类型()</p>
<pre><code>Animal animal = new Cat("元宝",2);
</code></pre>
<p>animal是父类类型，但可以引用一个子类对象，因为是从小范围向大范围的转换。</p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;向上转型的几种方法：&lt;/font&gt;</strong></p>
<ul>
<li>&lt;font style="color:rgb(51,51,51);"&gt;直接赋值&lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;方法的传参&lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;方法的返回值&lt;/font&gt;</li>
</ul>
<p>注意事项：</p>
<ul>
<li>通过父类的引用，调用子类特有的方法是无法直接调用的，这里只能调用父类自己的。</li>
</ul>
<pre><code>class Animal{
    public String name;
    public Animal(String name){
        this.name = name;
    }
}

class Dog extends Animal{
    public Dog(String name){
        super(name);
    }
}

class Cat extends Animal{
    public Cat(String name){
        super(name);
    }
}

public class Test {
    //方法二：方法的传参
    public static void function(Animal animal){
    }
    //方法三：返回值
    public static Animal function2(){
        Dog dog = new Dog("Papy Dog");
        return dog;
    }
    public static void main(String[] args) {
        //方法一：直接赋值
        Animal dog = new Dog("Papy Dog");
        Cat cat = new Cat("Papy Cat");
        //方法二：方法的传参
        function(cat);
        //方法三：返回值
        Animal dog2 = function2();
    }
}
</code></pre>
<h4>1.2.2向下转型</h4>
<p>将一个子类对象经过向上转型之后当成父类方法使用，再无法调用子类的方法，但有时候可能需要调用子类特有的方法，此时：将父类引用再还原为子类对象即可，即向下转换。</p>
<p><img src="./polymorphism1.png" alt="" /></p>
<p>继续根据向上转型的类举例：</p>
<pre><code>class Animal{
    public String name;
    public Animal(String name){
        this.name = name;
    }
}

class Dog extends Animal{
    public Dog(String name){
        super(name);
    }
}

class Cat extends Animal{
    public Cat(String name){
        super(name);
    }
}

public class Test{
    public static void main(String[] args){
        Dog dog = new Dog("Papy Dog");
        Cat cat = new Cat("Papy Cat");
        Animal animal = new Dog("Papy Dog");	//向上转型
        Dog dog = (Dog)animal;		//合法的向下转型
        //cat = (Cat)animal;		//非法的向下转型
    }
}
</code></pre>
<p>注意向下转型的要求：</p>
<ul>
<li>必须先有向上转型（前提）</li>
<li>对象的真实类型必须是目标子类或者其子类</li>
<li>需要强制类型转换（Cast）</li>
<li>推荐<code>instanceof</code>检查类型（安全转型方式）</li>
</ul>
<pre><code>if (a instanceof Dog) {
    Dog d = (Dog) a;
    d.bark();
}
</code></pre>
<p>来个比喻呢就是</p>
<ul>
<li>向上转型：你把 “iPhone” 当成 “手机” 来用，没问题。</li>
<li>向下转型：你把 “手机” 强行当成 “iPhone” 用，只有当它本来就是 iPhone 才行，如果本来是 “小米”，那么立马报错。</li>
</ul>
<h3>1.3重写与重载</h3>
<h4>1.3.1重写</h4>
<pre><code>class Animal{
    public String name;
    public Animal(String name){
        this.name = name;
    }
    public void eat(){
        System.out.println("Animal在吃饭");
    }
}

class Dog extends Animal{
    public Dog(String name){
        super(name);
    }
    public void eat(){
        System.out.println("Dog在吃饭");
    }
}

//输出结果：Dog在吃饭
</code></pre>
<p>以上例子构成了重写，按规则是要调用父类的eat的，但是构成了<strong>动态绑定（</strong>&lt;font style="color:#DF2A3F;"&gt;伏笔&lt;/font&gt;<strong>）</strong></p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;重写(override)&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;：也称为覆盖。重写是子类对父类非静态、非private修饰，非final修饰，非构造方法等的实现过程进行重新编写, &lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;形参列表与被重写方法的参数列表必须完全相同&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;方法重写的规则：&lt;/font&gt;</p>
<ul>
<li>&lt;font style="color:rgb(51,51,51);"&gt;子类在重写父类的方法时，一般必须与父类方法原型一致： 返回值类型方法名 (参数列表) 要完全一致 &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;被重写的方法返回值类型可以不同，&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;但是必须是具有父子关系&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;的&lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;访问权限&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;不能比父类中被重写的方法的访问权限更低&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。例如：如果父类方法被public修饰，则子类中重写该方法就不能声明为 protected &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;父类&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;被static、private修饰的方法&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;、&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;构造方法&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;都不能被重写。 &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;重写的方法, 可以使用 @Override 注解来显式指定. 有了这个注解能帮我们进行一些合法性校验. 例如不小心将方法名字拼写错了 (比如写成 aet), 那么此时编译器就会发现父类中没有 aet 方法, 就会编译报错, 提示无法构成重写.&lt;/font&gt;</li>
</ul>
<p>&lt;font style="color:rgb(51,51,51);"&gt;对第二点解释：&lt;/font&gt;</p>
<pre><code>class Parent {
    Number getValue() { return 1; }
}
class Child extends Parent {
    @Override
    Integer getValue() { return 2; } // OK，Integer 是 Number 的子类
}
</code></pre>
<h4>1.3.2重写与重载的区别</h4>
<table>
<thead>
<tr>
<th>区别点</th>
<th>重载Overload</th>
<th>重写Override</th>
</tr>
</thead>
<tbody>
<tr>
<td>参数列表</td>
<td>必须修改</td>
<td>一定不能修改</td>
</tr>
<tr>
<td>返回类型</td>
<td>可以修改</td>
<td>一定不能修改[除非构成父子类关系]</td>
</tr>
<tr>
<td>访问限定符</td>
<td>可以修改</td>
<td>可以减少或删除，一定不能抛出新的或者更广的异常</td>
</tr>
<tr>
<td>访问</td>
<td>可以修改</td>
<td>一定不能做更严格的限制（可以降低限制）</td>
</tr>
</tbody>
</table>
<p>重写的原则：</p>
<p>对于已经投入使用的类，尽量不要进行修改。最好的方式是：重新定义一个新的类，来重复利用其中共性的内容，并且添加或者改动新的内容。</p>
<ul>
<li><strong>父类：DrinkMachine（饮料机）</strong></li>
</ul>
<pre><code>class DrinkMachine {
    public void makeDrink() {
        System.out.println("制作普通饮料");
    }
}
</code></pre>
<ul>
<li><strong>子类：CoffeeMachine（咖啡机）</strong></li>
</ul>
<pre><code>class CoffeeMachine extends DrinkMachine {
    @Override
    public void makeDrink() {
        System.out.println("制作咖啡");
    }
}
</code></pre>
<p>eg：比如饮料机。父类：DrinkMachine 子类：咖啡机CoffeeMachine，如果直接改<code>DrinkMachine</code>的<code>makeDrink()</code>方法把“普通饮料“换成”咖啡“，那以后所有买饮料的人都只能喝咖啡了， 果汁党就会抗议 😂。</p>
<p>正确做法应该就是新建<code>CoffeeMachine</code>，只改子类逻辑，不影响父类</p>
<h4>1.3.3动态绑定与静态绑定</h4>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;静&lt;/font&gt;态绑定</strong>：也称为前期绑定(早绑定)，即在编译时，根据用户所传递实参类型就确定了具体调用那个方法。典型代表函数重载。</p>
<p><strong>动态绑定</strong>：也称为后期绑定(晚绑定)，即在编译时，不能确定方法的行为，需要等到程序运行时，才能够确定具体调用那个类的方法。</p>
<p>解释如下：</p>
<pre><code>class Parent {
    void hello() {
        System.out.println("Parent hello");
    }
}

class Child extends Parent {
    @Override
    void hello() {
        System.out.println("Child hello");
    }
}

public class Main {
    public static void main(String[] args) {
        Parent p = new Child();  // 向上转型
        p.hello();               // 打印什么？
    }
}
</code></pre>
<p>(1)编译期</p>
<ul>
<li>编译器只知道 <code>p</code> 的静态类型是 <code>Parent</code>，所以它会去 <code>Parent</code> 类中查找有没有 <code>hello()</code> 方法。</li>
<li>查到以后，编译器就说：“行，这个调用是合法的。”</li>
<li>但 <strong>它不会决定最终调用哪个版本</strong>。</li>
</ul>
<p>👉 所以编译期只检查 <strong>方法是否存在</strong>，但不绑定到具体实现。</p>
<p>(2)运行期</p>
<ul>
<li>运行时，<code>p</code> 实际上引用的是一个 <code>Child</code> 对象。</li>
<li>Java 虚拟机（JVM）在调用 <code>p.hello()</code> 的时候，会根据 <strong>对象的真实类型（运行时类型）</strong> 来决定到底执行 <code>Parent.hello()</code> 还是 <code>Child.hello()</code>。</li>
<li>因为 <code>Child</code> 重写了 <code>hello()</code>，所以 JVM 调用的是 <code>Child</code> 的版本。</li>
</ul>
<p>这就是动态绑定（Dynamic Dispatch）</p>
<p><strong>JVM底层原理</strong></p>
<p>在JVM的方法区（Java8后都是元空间Metaspace）里，每个类都会有一张<strong>方法表</strong> <strong>（Method Table，类似 C++ 的 vtable）  。</strong></p>
<p><strong>1.类加载时</strong>：</p>
<ul>
<li>JVM 为 <code>Parent</code> 类建立方法表，里面有 <code>hello -&gt; Parent.hello</code> 的映射。</li>
<li>JVM 为 <code>Child</code> 类建立方法表，发现 <code>hello</code> 被重写了，就把 <code>hello -&gt; Child.hello</code> 替换掉。</li>
</ul>
<p><strong>2.对象创建时</strong>：</p>
<ul>
<li>当<code>new Child()</code>执行时，对象头里会有一个指针，指向<code>Child</code>的类信息（包括方法表）。</li>
</ul>
<p><strong>3.方法调用时</strong>：</p>
<p>执行 <code>p.hello()</code>，JVM 并不会直接调用 <code>Parent.hello()</code>，而是：</p>
<ul>
<li>看 <code>p</code> 的运行时类型 → 发现它指向 <code>Child</code></li>
<li>在 <code>Child</code> 的方法表里查找 <code>hello()</code> → 找到 <code>Child.hello()</code> → 调用它。</li>
</ul>
<p>👉 因此，真正起作用的是 <strong>运行时对象的类信息 + 方法表查找</strong>，而不是变量的声明类型 。</p>
<p>我们可以类比为：</p>
<p>编译期：老师点名检查-&gt;”这个学生‘回答问题’的功能吗？“（有）</p>
<p>运行期：真正回答问题的是具体学生，可能每个人回答的方式不同。</p>
<h3>1.4回顾多态</h3>
<h4>1.4.1多态思想</h4>
<pre><code>class Animal{
    public String name;
    public Animal(String name){
        this.name = name;
    }
    public void eat(){
        System.out.println(this.name+"在吃饭");
    }
}

class Dog extends Animal{
    public Dog(String name){
        super(name);
    }
    @Override
    public void eat(){
        System.out.println(this.name+"在吃饭");
    }
}

class Cat extends Animal{
    public Cat(String name){
        super(name);
    }
    @Override
    public void eat(){
        System.out.println(this.name+"在吃饭");
    }
}

public class Test{
    public static void func(Animal animal){
        animal.eat();
    }
    public static void main(String[] args) {
        Dog dog = new Dog("Papy Dog");
        Cat cat = new Cat("Papy Cat");
        func(dog);
        func(cat);
    }
}

输出结果：
Papy Dog在吃饭
Papy Cat在吃饭
</code></pre>
<p>调用同一个方法，得到不同的结果（多态思想）</p>
<h4>1.4.2多态的优缺点</h4>
<p><strong>优点：</strong></p>
<pre><code>public static void drawShapes() {
    Rect rect = new Rect();
    Cycle cycle = new Cycle();
    Flower flower = new Flower();
    String[] shapes = {"cycle", "rect", "cycle", "rect", "flower"};
    for (String shape : shapes) {
        if (shape.equals("cycle")) {
            cycle.draw();
        } else if (shape.equals("rect")) {
            rect.draw();
        } else if (shape.equals("flower")) {
            flower.draw();
        }
    }
}
</code></pre>
<p>我们使用多态的话，就能优化一下我们的代码</p>
<pre><code>public static void drawShapes() {
// 我们创建了一个 Shape 对象的数组.
    Shape[] shapes = {new Cycle(), new Rect(), new Cycle(),
            new Rect(), new Flower()};
    for (Shape shape : shapes) {
        shape.draw();
    }
}
</code></pre>
<p>缺点：</p>
<p>多态的缺陷--代码的运行效率降低</p>
<blockquote>
<p>1.属性没有多态性</p>
<p>当父类和子类都有同名属性的时候，通过父类引用，只能引用父类自己的成员属性</p>
<p>2.构造方法没有多态性</p>
</blockquote>
<p>以上是我关于Java多态的笔记分享，</p>
<p>感谢你读到这里，这也是我学习路上的一个小小记录。希望以后回头看时，能看到自己的成长~</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 类与对象：实例化、构造方法、封装与静态成员</title>
    <link href="https://blog.syrena.cn/posts/class-object/class-object/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/class-object/class-object/</id>
    <published>2025-09-22T00:00:00.000Z</published>
    <updated>2025-09-22T00:00:00.000Z</updated>
    <summary>深度学习Java的类与对象.</summary>
    <content type="html"><![CDATA[<h2>1.定义类</h2>
<p><img src="./java-object.png" alt="" /></p>
<pre><code>public class Dog {
    String breed;
    int size;
    String colour;
    int age;
 
    void eat() {
    }
 
    void run() {
    }
 
    void sleep(){
    }
 
    void name(){
    }
}
</code></pre>
<ul>
<li><strong>局部变量</strong>：在方法、构造方法或者语句块中定义的变量被称为局部变量。变量声明和初始化都是在方法中，方法结束后，变量就会自动销毁。</li>
<li><strong>成员变量</strong>：成员变量是定义在类中，方法体之外的变量。这种变量在创建对象的时候实例化。成员变量可以被类中方法、构造方法和特定类的语句块访问。</li>
<li><strong>类变量</strong>：类变量也声明在类中，方法体之外，但必须声明为 static 类型。</li>
</ul>
<p>注意：</p>
<p>1.一般一个文件当中只定义一个类</p>
<p>2.main方法所在的类一般要使用public修饰</p>
<p>3.public修饰的类必须要与文件名相同</p>
<p>4.不要随意修改public修饰的类的名字</p>
<h2>2.创建对象（类的实例化）</h2>
<p>对象是根据类创建的。在Java中，使用关键字 new 来创建一个新的对象。创建对象需要以下三步：</p>
<ul>
<li><strong>声明</strong>：声明一个对象，包括对象名称和对象类型。</li>
<li><strong>实例化</strong>：使用关键字 new 来创建一个对象。</li>
<li><strong>初始化</strong>：使用 new 创建对象时，会调用构造方法初始化对象。</li>
</ul>
<p>下面是一个创建对象的例子：</p>
<pre><code>public class Puppy{
   public Puppy(String name){
      //这个构造器仅有一个参数：name
      System.out.println("小狗的名字是 : " + name ); 
   }
   public static void main(String[] args){
      // 下面的语句将创建一个Puppy对象
      Puppy myPuppy = new Puppy( "Sirens" );
   }
}
</code></pre>
<p>输出内容为以下：</p>
<p><code>小狗的名字是：Sirens</code></p>
<h2>3.访问实例（成员）变量与方法</h2>
<p>通过已创建的对象来访问成员方法：</p>
<pre><code>/* 实例化对象 */
Object referenceVariable = new Constructor();
/* 访问类中的变量 */
referenceVariable.variableName;
/* 访问类中的方法 */
referenceVariable.methodName();
</code></pre>
<p>实质上在main方法中通过<code>.</code>来访问对象中的变量以及方法</p>
<h2>4.构造方法</h2>
<p>每个类都有构造方法。如果没有显式地为类定义构造方法，Java 编译器将会为该类提供一个默认构造方法。</p>
<p>在创建一个对象的时候，至少要调用一个构造方法。<strong>构造方法的名称必须与类同名</strong>，一个类可以有多个构造方法。</p>
<p>下面是一个构造方法示例：</p>
<pre><code>public class Puppy{
    public Puppy(){
    }
 
    public Puppy(String name){
        // 这个构造器仅有一个参数：name
    }
}
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;注意： &lt;/font&gt;</p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;this(...)必须是构造方法中第一条语句&lt;/font&gt;</strong></p>
<h2>5.综上范例</h2>
<pre><code>public class Employee{
	private String name;
	private int age;
	private String designation;//称号
	private double salary;
	
	public Employee(String name,int age){
		this.name = name;
		this.age = age;
	}
	
	public void setAge(int age){
		this.age = age;
	}
	
	public int getAge(){
		return age;
	}
	
	public void setDesignation(String designation){
		this.designation = designation;
	}

	public String getDesignation(){
		return designation;
	}
	
	public void setSalary(double salary){
		this.salary = salary;
	}
		
	public double getSalary(){
			return salary;
		}

	// 打印信息
	public void printEmployee() {
		System.out.println(this);
	}

	// 重写 toString 方法
	@Override
	public String toString() {
		return "名字: " + name + "\n" +
				"年龄: " + age + "\n" +
				"职位: " + designation + "\n" +
				"薪水: " + salary;
	}
}
</code></pre>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;this引用指向当前对象(成员方法运行时调用该成员方法的对象)，在成员方法中所有成员变量的操作，都是通过该 &lt;/font&gt;</strong></p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;引用去访问&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。只不过所有的操作对用户是透明的，即用户不需要来传递，编译器自动完成。&lt;/font&gt;</p>
<ul>
<li>在类中访问类的变量时，尽量&lt;font style="color:#DF2A3F;"&gt;养成使用&lt;/font&gt;<code>this</code>&lt;font style="color:#DF2A3F;"&gt;的习惯&lt;/font&gt;，防止出现形参赋值给形参的情况</li>
</ul>
<h3>默认初始化</h3>
<p>对象空间被申请好之后，对象中包含的成员已经设置好了初始值，比如</p>
<table>
<thead>
<tr>
<th>数据类型</th>
<th>默认值</th>
</tr>
</thead>
<tbody>
<tr>
<td>byte</td>
<td>0</td>
</tr>
<tr>
<td>char</td>
<td>'\u0000'</td>
</tr>
<tr>
<td>short</td>
<td>0</td>
</tr>
<tr>
<td>int</td>
<td>0</td>
</tr>
<tr>
<td>long</td>
<td>0L</td>
</tr>
<tr>
<td>boolean</td>
<td>false</td>
</tr>
<tr>
<td>float</td>
<td>0.0f</td>
</tr>
<tr>
<td>double</td>
<td>0.0</td>
</tr>
<tr>
<td>reference</td>
<td>null</td>
</tr>
</tbody>
</table>
<h2>6.源文件声明规则</h2>
<p>&lt;font style="color:#262626;"&gt;当在一个源文件中定义多个类，并且还有 import 语句和 package 语句时，要特别注意这些规则。&lt;/font&gt;</p>
<ul>
<li>&lt;font style="color:#262626;"&gt;一个源文件中只能有一个 public 类&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;一个源文件可以有多个非 public 类&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;源文件的名称应该和 public 类的类名保持一致。例如：源文件中 public 类的类名是 Employee，那么源文件应该命名为Employee.java。&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;如果一个类定义在某个包中，那么 package 语句应该在源文件的首行。&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;如果源文件包含 import 语句，那么应该放在 package 语句和类定义之间。如果没有 package 语句，那么 import 语句应该在源文件中最前面。&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;import 语句和 package 语句对源文件中定义的所有类都有效。在同一源文件中，不能给不同的类不同的包声明。&lt;/font&gt;</li>
</ul>
<h2>7.封装</h2>
<p>封装可以被认为是一个保护屏障，防止该类的代码和数据被外部类定义的代码随机访问。</p>
<p>要访问该类的代码和数据，必须通过严格的接口控制。</p>
<p>封装最主要的功能在于我们能修改自己的实现代码，而不用修改那些调用我们代码的程序片段。</p>
<p>适当的封装可以让程式码更容易理解与维护，也加强了程式码的安全性。</p>
<h3>7.1访问限定符</h3>
<table>
<thead>
<tr>
<th>范围</th>
<th>private</th>
<th>default</th>
<th>protected</th>
<th>public</th>
</tr>
</thead>
<tbody>
<tr>
<td>同一包中的同一类</td>
<td>✔</td>
<td>✔</td>
<td>✔</td>
<td>✔</td>
</tr>
<tr>
<td>同一包中的不同类</td>
<td></td>
<td>✔</td>
<td>✔</td>
<td>✔</td>
</tr>
<tr>
<td>不同包中的子类</td>
<td></td>
<td></td>
<td>✔</td>
<td>✔</td>
</tr>
<tr>
<td>不同包中的非子类</td>
<td></td>
<td></td>
<td></td>
<td>✔</td>
</tr>
</tbody>
</table>
<p>&lt;font style="color:rgb(51,51,51);"&gt;public：可以理解为一个人的外貌特征，谁都可以看得到 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;default: &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;对于自己家族中&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;(&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;同一个包中&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;)&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;不是什么秘密，对于其他人来说就是隐私了 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;private：只有自己知道，其他人都不知道&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;【&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;说明&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;】 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;protected&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;主要是用在继承中，继承部分详细介绍 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;default&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;权限指：什么都不写时的默认权限 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;访问权限除了可以限定类中成员的可见性，也可以控制类的可见性&lt;/font&gt;</p>
<h3>&lt;font style="color:rgb(51,51,51);"&gt;7.2实现Java封装的步骤&lt;/font&gt;</h3>
<p>1.修改属性的可见性来限制对属性的访问（一般限制为private），例如：</p>
<pre><code>public class Person {
    private String name;
    private int age;
}
</code></pre>
<p>&lt;font style="color:#262626;"&gt;这段代码中，将 &lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;name&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; 和 &lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;age&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; 属性设置为私有的，只能本类才能访问，其他类都访问不了，如此就对信息进行了隐藏。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;2.对每个值属性提供对外的公共方法访问，也就是创建一对赋取值的方法，用于对私有属性的访问，例如：&lt;/font&gt;</p>
<pre><code>public class Person{
    private String name;
    private int age;
​
    public int getAge(){
      return age;
    }
​
    public String getName(){
      return name;
    }
​
    public void setAge(int age){
      this.age = age;
    }
​
    public void setName(String name){
      this.name = name;
    }
}
</code></pre>
<p>&lt;font style="color:#262626;"&gt;采用 &lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;this&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; 关键字是为了解决实例变量（private String name）和局部变量（setName(String name)中的name变量）之间发生的同名的冲突。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;以上实例中public方法是外部类访问该类成员变量的入口。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;通常情况下，这些方法被称为getter和setter方法。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;因此，任何要访问类中私有成员变量的类都要通过这些<strong>getter</strong>和<strong>setter</strong>方法。&lt;/font&gt;</p>
<h2>&lt;font style="color:#262626;"&gt;8.Java包（Package）&lt;/font&gt;</h2>
<p>&lt;font style="color:#262626;"&gt;为了更好地组织类，Java 提供了包机制，用于区别类名的命名空间&lt;/font&gt;</p>
<p><strong>&lt;font style="color:#262626;"&gt;包的作用&lt;/font&gt;</strong></p>
<ul>
<li>&lt;font style="color:#262626;"&gt;1、把功能相似或相关的类或接口组织在同一个包中，方便类的查找和使用。&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;2、如同文件夹一样，包也采用了树形目录的存储方式。同一个包中的类名字是不同的，不同的包中的类的名字是可以相同的，当同时调用两个不同包中相同类名的类时，应该加上包名加以区别。因此，包可以避免名字冲突。&lt;/font&gt;</li>
<li>&lt;font style="color:#262626;"&gt;3、包也限定了访问权限，拥有包访问权限的类才能访问某个包中的类。&lt;/font&gt;</li>
</ul>
<p>&lt;font style="color:#262626;"&gt;Java 使用包（package）这种机制是为了防止命名冲突，访问控制，提供搜索和定位类（class）、接口、枚举（enumerations）和注释（annotation）等。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;包语句的语法格式为：&lt;/font&gt;</p>
<pre><code>package pkg1[．pkg2[．pkg3…]];
</code></pre>
<h3>8.1 创建包</h3>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;基本规则 &lt;/font&gt;</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;在文件的最上方加上一个&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt; package &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;语句指定该代码在哪个包中&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;. &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;包名需要尽量指定成唯一的名字, 通常会用公司的域名的颠倒形式(例如 io.github.sirens007 )&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;包名要和代码路径相匹配. 例如创建&lt;/font&gt;<code>io.github.sirens007</code>&lt;font style="color:rgb(51,51,51);"&gt;的包, 那么会存在一个对应的路径 &lt;/font&gt;<code>io.github.sirens007</code>&lt;font style="color:rgb(51,51,51);"&gt;来存储代码.&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;如果一个类没有 package 语句, 则该类被放到一个默认包中.&lt;/font&gt;</p>
<p><img src="./java-image.png" alt="" /></p>
<p>即图中<code>软件包</code>，点击后输入<code> io.github.sirens007</code>&lt;font style="color:rgb(51,51,51);"&gt;则会创建一个三级目录&lt;/font&gt;</p>
<h3>8.2 import 关键字</h3>
<p>&lt;font style="color:#262626;"&gt;为了能够使用某一个包的成员，我们需要在 Java 程序中明确导入该包。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;在 Java 中，&lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;import&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; &lt;/font&gt;&lt;font style="color:#262626;"&gt;关键字用于导入其他类或包中定义的类型，以便在当前源文件中使用这些类型。&lt;/font&gt;</p>
<p><strong>&lt;font style="color:#262626;"&gt;import&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; &lt;/font&gt;&lt;font style="color:#262626;"&gt;关键字用于引入其他包中的类、接口或静态成员，它允许你在代码中直接使用其他包中的类，而不需要完整地指定类的包名。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;在 java 源文件中 import 语句必须位于 Java 源文件的头部，其语法格式为：&lt;/font&gt;</p>
<pre><code>import package1[.package2…].(classname|*);
</code></pre>
<p><code>import</code> 语句位于 <code>package</code> 语句之后：</p>
<pre><code>// 第一行非注释行是 package 语句
package com.example;
 
// import 语句引入其他包中的类
import java.util.ArrayList;
import java.util.List;
 
// 类的定义
public class MyClass {
    // 类的成员和方法
}
</code></pre>
<p>&lt;font style="color:rgb(51, 51, 51);background-color:rgb(250, 252, 253);"&gt;如果在一个包中，一个类想要使用本包中的另一个类，那么该包名可以省略。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51, 51, 51);background-color:rgb(250, 252, 253);"&gt;可以使用 import语句来引入一个特定的类：&lt;/font&gt;</p>
<pre><code>import  io.github.sirens007;
</code></pre>
<p>&lt;font style="color:#262626;"&gt;这样，你就可以在当前源文件中直接使用 sirens007 类的方法、变量或常量。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;也可以使用通配符 &lt;/font&gt;<code>*</code>&lt;font style="color:#262626;"&gt; 来引入整个包或包的子包：&lt;/font&gt;</p>
<p><code>import io.sirens007.mypackage.*;</code></p>
<p>&lt;font style="color:#262626;"&gt;这样，你可以导入 &lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;io.sirens007.mypackage&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; 包中的所有类，从而在当前源文件中使用该包中的任何类的方法、变量或常量。注意，使用通配符 &lt;/font&gt;<code>*</code>&lt;font style="color:#262626;"&gt; 导入整个包时，只会导入包中的类，而&lt;/font&gt;&lt;font style="color:#DF2A3F;"&gt;不会导入包中的&lt;/font&gt;<strong>&lt;font style="color:#DF2A3F;"&gt;子包&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt;。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;在导入类或包时，你需要提供&lt;/font&gt;<strong>&lt;font style="color:#DF2A3F;"&gt;类的完全限定名或包的完全限定名&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt;。完全限定名包括包名和类名的组合，以点号 &lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;.&lt;/font&gt;</strong> 分隔</p>
<pre><code>import java.util.ArrayList; // 引入 java.util 包中的 ArrayList 类
import java.util.*; // 引入 java.util 包中的所有类

import com.example.MyClass; // 引入 com.example 包中的 MyClass 类
import com.example.*; // 引入 com.example 包中的所有类
</code></pre>
<h4>8.2.1 常见的包</h4>
<p>&lt;font style="color:rgb(51,51,51);"&gt;1. java.lang:系统常用基础类(String、Object),此包从JDK1.1后自动导入。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;2. java.lang.reflect:java &lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;反射编程包&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;; &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;3. java.net:&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;进行网络编程开发包。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;4. java.sql:&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;进行数据库开发的支持包。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;5. java.util:&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;是&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;java&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;提供的工具程序包。&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;(&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;集合类等&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;) &lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;非常重要 &lt;/font&gt;</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;6. java.io:I/O编程开发包。&lt;/font&gt;</p>
<h3>&lt;font style="color:rgb(51,51,51);"&gt;8.3 Package 的目录结构&lt;/font&gt;</h3>
<p>类放在包中会有两种主要的结果：</p>
<ul>
<li>包名成为类名的一部分，正如我们前面讨论的一样。</li>
<li>包名必须与相应的字节码所在的目录结构相吻合。</li>
</ul>
<p>下面是管理你自己 java 中文件的一种简单方式：</p>
<p>将类、接口等类型的源码放在一个文本中，这个文件的名字就是这个类型的名字，并以.java作为扩展名。例如：</p>
<pre><code>// 文件名 :  Car.java
package vehicle;
 
public class Car {
   // 类实现  
}
</code></pre>
<p>&lt;font style="color:rgb(51, 51, 51);background-color:rgb(250, 252, 253);"&gt;接下来，把源文件放在一个目录中，这个目录要对应类所在包的名字。&lt;/font&gt;</p>
<pre><code>....\vehicle\Car.java
</code></pre>
<p>现在，正确的类名和路径将会是如下样子：</p>
<ul>
<li>类名 -&gt; vehicle.Car</li>
<li>路径名 -&gt; vehicle\Car.java (在 windows 系统中)</li>
</ul>
<p>&lt;font style="color:#262626;"&gt;通常，一个公司使用它互联网域名的颠倒形式来作为它的包名.例如：互联网域名是 runoob.com，所有的包名都以 com.runoob 开头。包名中的每一个部分对应一个子目录。&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;例如：有一个 &lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt;io.github.test&lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt; 的包，这个包包含一个叫做 Hello.java 的源文件，那么相应的，应该有如下面的一连串子目录：&lt;/font&gt;</p>
<pre><code>....\io\github\test\Hello.java
</code></pre>
<p>&lt;font style="color:#262626;"&gt;编译的时候，编译器为包中定义的每个类、接口等类型各创建一个不同的输出文件，输出文件的名字就是这个类型的名字，并加上 .class 作为扩展后缀。 例如：&lt;/font&gt;</p>
<pre><code>// 文件名: Hello.java
 
package io.github.test;
public class Hello {
      
}
class Google {
      
}
</code></pre>
<p>&lt;font style="color:#262626;"&gt;现在，我们用-d选项来编译这个文件，如下：&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;$javac -d . Hello.java&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;这样会像下面这样放置编译了的文件：&lt;/font&gt;</p>
<pre><code>.\io\github\test\Hello.class
.\io\github\test\Google.class
</code></pre>
<p>&lt;font style="color:#262626;"&gt;你可以像下面这样来导入所有&lt;/font&gt;<strong>&lt;font style="color:#262626;"&gt; \io\github\test\ &lt;/font&gt;</strong>&lt;font style="color:#262626;"&gt;中定义的类、接口等：&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;import io.github.test.*;&lt;/font&gt;</p>
<p>&lt;font style="color:#262626;"&gt;编译之后的 .class 文件应该和 .java 源文件一样，它们放置的目录应该跟包的名字对应起来。但是，并不要求 .class 文件的路径跟相应的 .java 的路径一样。你可以分开来安排源码和类的目录。&lt;/font&gt;</p>
<pre><code>&lt;path-one&gt;\sources\io\github\test\Hello.java
&lt;path-two&gt;\classes\io\github\test\Google.class
</code></pre>
<p>&lt;font style="color:#262626;"&gt;这样，你可以将你的类目录分享给其他的编程人员，而不用透露自己的源码。用这种方法管理源码和类文件可以让编译器和java 虚拟机（JVM）可以找到你程序中使用的所有类型。&lt;/font&gt;</p>
<h2>9.static 成员</h2>
<h3>9.1static 修饰成员变量</h3>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;static修饰的成员变量，称为静态成员变量&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;，静态成员变量最大的特性：&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;不属于某个具体的对象，是所有对象所共享的&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。&lt;/font&gt;</p>
<p>它与类相关而不是与实例相关，即无论创建多少个类实例，静态变量在内存中只有一份拷贝，被所有实例共享。</p>
<p>重点：类的静态成员变量特性</p>
<blockquote>
<p>1.不属于某个具体的对象，是类的属性，所有对象共享的，不存储在某个对象的空间中</p>
<p>2.既可以通过对象访问，也可以通过类名访问，但一般更推荐使用类名访问</p>
<p>3.静态类变量存储在方法区中</p>
<p>4.&lt;font style="color:rgb(51,51,51);"&gt;生命周期伴随类的一生(即：随类的加载而创建，随类的卸载而销毁)&lt;/font&gt;</p>
</blockquote>
<p><strong>类的加载和卸载</strong></p>
<p><strong>加载（Load）</strong>：当你第一次使用这个类（比如 <code>new</code>、访问静态变量/方法）时，JVM 的类加载器会把它加载到 <strong>方法区</strong>。</p>
<p><strong>卸载（Unload）</strong>：当一个类不再被任何地方使用时，JVM 才有可能把它卸载。</p>
<h3>9.2static 修饰成员方法</h3>
<p>&lt;font style="color:rgb(51,51,51);"&gt;Java中，&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;被static修饰的成员方法称为静态成员方法，是类的方法，不是某个对象所特有的&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。静态成员一般是通过静态方法来访问的。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;重点：静态方法特性&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;1.不属于某个具体的对象，是类方法&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;2.可以通过对象调用，也可以通过 &lt;/font&gt;<code>类名.静态方法名(...)</code>&lt;font style="color:rgb(51,51,51);"&gt;方式调用，更推荐类名调用&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;3.不能在静态方法中访问任何非静态成员变量&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;4. 静态方法中不能调用任何非静态方法，因为非静态方法有this参数，在静态方法中调用时候无法传递this引用&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;5. 静态方法无法重写，不能用来实现多态(此处大家暂时不用管，后序多态位置详细讲解)。&lt;/font&gt;</p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;补充第三点：&lt;/font&gt;</strong></p>
<ul>
<li><strong>静态变量/方法（static）</strong>&lt;font style="color:rgb(51,51,51);"&gt;<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;属于 &lt;/font&gt;<strong>类本身</strong>&lt;font style="color:rgb(51,51,51);"&gt;，不依赖某个对象，类一加载就存在。<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;调用方式：&lt;/font&gt;<code>类名.方法()</code>&lt;font style="color:rgb(51,51,51);"&gt; 或者 &lt;/font&gt;<code>对象.方法()</code>&lt;font style="color:rgb(51,51,51);"&gt;（其实推荐用类名）。&lt;/font&gt;</li>
<li><strong>非静态变量/方法（实例成员）</strong>&lt;font style="color:rgb(51,51,51);"&gt;<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;属于 &lt;/font&gt;<strong>对象实例</strong>&lt;font style="color:rgb(51,51,51);"&gt;，必须通过 &lt;/font&gt;<code>new</code>&lt;font style="color:rgb(51,51,51);"&gt; 出来的对象才会存在。<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;调用方式：&lt;/font&gt;<code>对象.方法()</code>&lt;font style="color:rgb(51,51,51);"&gt;。&lt;/font&gt;</li>
</ul>
<p><strong>Q1:为什么静态方法不能访问非静态变量呢</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;因为静态方法执行的时候，对象可能还没有创建&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;eg:&lt;/font&gt;</p>
<pre><code>class Demo {
    int age = 10;         // 非静态变量（实例变量）
    static int count = 0; // 静态变量

    static void printInfo() {
        System.out.println(age); // ❌ 报错！
    }
}
</code></pre>
<p>运行逻辑：</p>
<ol>
<li>JVM 加载 <code>Demo</code> 类时，<code>count</code> 已经存在（方法区 / 元空间里）。</li>
<li>但是 <code>age</code> 是实例变量，只有在执行 <code>new Demo()</code> 时，才会分配内存到堆里。</li>
<li><code>static void printInfo()</code> 可能在没有任何对象的时候被调用，这时 JVM 不知道该取哪个对象的 <code>age</code>。</li>
</ol>
<p>所以编译器直接禁止你在静态方法里访问 <code>age</code>。</p>
<p><strong>Q2:&lt;font style="color:rgb(51,51,51);"&gt;. 静态方法中为什么不能调用任何非静态方法？&lt;/font&gt;</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;我们先复习一下静态和非静态方法&lt;/font&gt;</p>
<p><strong>静态方法</strong>&lt;font style="color:rgb(51,51,51);"&gt;<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;属于类本身，类加载时就存在，不依赖对象。调用时 JVM 直接用「类信息」执行，不需要 new 对象。  &lt;/font&gt;</p>
<p><strong>非静态方法</strong>&lt;font style="color:rgb(51,51,51);"&gt;<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;属于对象实例，必须依赖对象才能调用。JVM 在调用时，默认会传入一个隐藏的 &lt;/font&gt;<code>this</code>&lt;font style="color:rgb(51,51,51);"&gt; 指针，告诉方法「我是谁」，这样方法才能访问对象里的实例变量。  &lt;/font&gt;</p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;他们的关键区别在于是否有&lt;/font&gt;</strong><code>this</code></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;调用非静态方法时，编译器会在字节码层面自动加上 &lt;/font&gt;<code>this</code>&lt;font style="color:rgb(51,51,51);"&gt;。<br />
&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;比如写：&lt;/font&gt;</p>
<pre><code>obj.sayHello();
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;编译后相当于：&lt;/font&gt;</p>
<pre><code>Demo.sayHello(obj);
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;调用静态方法时，不存在 &lt;/font&gt;<code>this</code>&lt;font style="color:rgb(51,51,51);"&gt;，因为它不依赖对象。&lt;/font&gt;</p>
<pre><code>Demo.staticHello();
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;编译后就是单纯的 &lt;/font&gt;<code>Demo.staticHello()</code>&lt;font style="color:rgb(51,51,51);"&gt;，没有对象上下文&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;因此静态方法里没有&lt;/font&gt;<code>this</code>&lt;font style="color:rgb(51,51,51);"&gt;，所以编译器根本不知道你想让哪个对象去执行这个非静态方法。&lt;/font&gt;</p>
<p>解决办法：</p>
<p>方法一：在静态方法里创建对象</p>
<pre><code>static void test() {
    Demo d = new Demo();
    d.sayHello(); // ✅ 通过对象调用非静态方法
}
</code></pre>
<p>方法二：把目标方法也改成静态</p>
<pre><code>static void sayHello() {
    System.out.println("Hello from static method");
}

static void test() {
    sayHello(); // ✅ 直接调用
}
</code></pre>
<p>总结就是： <strong>静态方法不能调用非静态方法，是因为静态方法没有 <code>this</code>，而非静态方法必须依赖 <code>this</code> 才能运行</strong></p>
<h3>9.3static 成员变量初始化</h3>
<p>初始化方法有以下：</p>
<p>1.就地初始化</p>
<p>2.通过get、set方法初始化</p>
<p>3.构造方法初始化（相对较少）</p>
<p>4.代码块初始化</p>
<h2>10.代码块</h2>
<h3>10.1 代码块分类</h3>
<p>使用<code>{}</code>定义的一段代码称为代码块，根据代码块定义的位置以及关键字，又可分为以下四种：</p>
<ul>
<li>静态代码块（<strong>一般用于初始化静态成员变量）</strong></li>
<li>非静态代码块/实例代码块/构造代码块</li>
<li>同步代码块...</li>
</ul>
<h3>代码块执行顺序</h3>
<p>静态代码块 -&gt; 实例代码块 -&gt; 构造方法</p>
<p><strong>&lt;font style="color:#DF2A3F;"&gt;注意：&lt;/font&gt;</strong> <strong>静态代码块只会执行一次（涉及知识点：双亲委派模型</strong></p>
<p>补充：如果静态/实例代码块有平级，那么就和他们各自类型的先后顺序有关系</p>
<p>构造方法主要看调用了的哪个构造</p>
<p><strong>&lt;font style="color:rgb(51,51,51);"&gt;注意事项 &lt;/font&gt;</strong></p>
<ul>
<li>&lt;font style="color:rgb(51,51,51);"&gt;静态代码块不管生成多少个对象，其只会执行一次 &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;静态成员变量是类的属性，因此是在&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;JVM&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;加载类时开辟空间并初始化的 &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;如果一个类中包含多个静态代码块，在编译代码时，编译器会按照定义的先后次序依次执行&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;(&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;合并&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;) &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;实例代码块只有在创建对象时才会执行&lt;/font&gt;</li>
</ul>
<pre><code>public class Main{
    String number = "123";
    {
        number = "125";
        System.out.println("eg coder");
    }

    Main(String number){
        this.number = number;
        System.out.println("struction func");
    }

    static{
        String number = "124";
        System.out.println("static coder");
    }
}


//输出结果：
//static coder
//eg coder
//struction func
</code></pre>
<h2>对象的打印</h2>
<p>平常我们直接打印对象时，会直接输出<code>对象名+ @ +一串字符</code> 这实际上是toString默认方法</p>
<p>如图下</p>
<p><img src="./java-image2.png" alt="" /></p>
<p>但当我们自己写上了toString方法的时候，就会输出我们自己想要的内容了</p>
<p>快捷键：<code>Alt+Insert</code>选中toString即可（静态方法默认不输出，需要可自己补充）</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 语法基础（二）：运算符、流程控制与数组</title>
    <link href="https://blog.syrena.cn/posts/javase2/javase2/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javase2/javase2/</id>
    <published>2025-09-16T00:00:00.000Z</published>
    <updated>2025-09-16T00:00:00.000Z</updated>
    <summary>初步学习Java语法.</summary>
    <content type="html"><![CDATA[<h2>第四节.运算符</h2>
<p><strong>1.仅补充%</strong></p>
<pre><code>System.out.println(10%3);//1
System.out.println(-10%3);//-1
System.out.println(10%-3);//1
System.out.println(-10%-3);//-1
</code></pre>
<p><strong>2.前置++与后置++</strong></p>
<blockquote>
<p>可以理解为+号在前面就先加，加号在后面就不加</p>
</blockquote>
<pre><code>int a = 1;
int b = ++a;
int c = a++;

System.out.println(b);	//2
System.out.println(c);	//1

//但要注意
a=a++;	//1,与c++不同
</code></pre>
<p><strong>3.关系运算符</strong></p>
<blockquote>
<p>&lt; , &gt; , &lt;= , &gt;= ,==  返回值：true or false;</p>
</blockquote>
<p><strong>4.逻辑运算符</strong></p>
<table>
<thead>
<tr>
<th>运算符</th>
<th>作用</th>
<th>示例</th>
<th>结果</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>&amp;&amp;</code></td>
<td>逻辑与（都为真才真）</td>
<td><code>true &amp;&amp; false</code></td>
<td><code>false</code></td>
</tr>
<tr>
<td>`</td>
<td></td>
<td>`</td>
<td>逻辑或（有一个真就真）</td>
</tr>
<tr>
<td><code>!</code></td>
<td>逻辑非（取反）</td>
<td><code>!true</code></td>
<td><code>false</code></td>
</tr>
</tbody>
</table>
<p>逻辑与</p>
<p>1.两个表达式必须都是布尔表达式</p>
<p>2.如果第一个表达式为假，那么就不会执行第二个表达式</p>
<p>逻辑或</p>
<p>1.两个表达式必须都是布尔表达式</p>
<p>2.只要有一个为真，那么整体为真</p>
<p>3.假设第一个为真，那么第二个就不会执行</p>
<p>逻辑非</p>
<p>1.作用在布尔表达式</p>
<p>&amp; 和 | 如果表达式结果为 boolean 时，也表示逻辑运算，但与 &amp;&amp; || 相比，他们不支持短路求值，</p>
<p>如果两边不是布尔表达式，那么还是按位与 按位或操作</p>
<pre><code>System.out.println(10 &gt; 20 &amp; 10 / 0 ==0)l;		//程序输出异常
System.out.println(10 &lt; 20 | 10 / 0 ==0)l;		//程序输出异常
</code></pre>
<p>他们逻辑符号两侧都会执行</p>
<p><strong>5.位运算符</strong></p>
<p>按位与</p>
<pre><code>00001010
00010100
---------
00000000
</code></pre>
<p>按位或</p>
<pre><code>00001010
00010100
---------
00011110
</code></pre>
<p>按位异或：如果该位为 0 则转为 1，如果该位为 1 则转为 0</p>
<blockquote>
<p>注意：</p>
<p>1.左移1位，相当于原数字 *2，左移N位，相当于原数字 *2 的N次方.</p>
<p>2.右移1位，相当于原数字 /2，右移N位，相当于原数字 /2 的N次方.</p>
<p>3.偶遇计算机计算位移效率高于计算乘除，当某个代码正好乘除 2 的N次方的时候可以用位移运算代替.</p>
<p>4.移动负数位或者移位位数过大都没有意义.</p>
</blockquote>
<h2>第五节.循环语句</h2>
<ol>
<li><code>for</code> 循环</li>
</ol>
<p><strong>适合：已知次数的循环</strong></p>
<pre><code>for (int i = 1; i &lt;= 5; i++) {
    System.out.println("第 " + i + " 次执行");
}
</code></pre>
<p>执行结果：打印 1 到 5。</p>
<hr />
<ol>
<li><code>while</code> 循环</li>
</ol>
<p><strong>适合：不知道循环次数，但有一个条件控制</strong></p>
<pre><code>int i = 1;
while (i &lt;= 5) {
    System.out.println("第 " + i + " 次执行");
    i++;
}
</code></pre>
<p>和上面的效果一样。</p>
<ol>
<li><code>do...while</code> 循环</li>
</ol>
<p><strong>至少执行一次</strong>，再判断条件。</p>
<pre><code>int i = 1;
do {
    System.out.println("第 " + i + " 次执行");
    i++;
} while (i &lt;= 5);
</code></pre>
<p>和 <code>while</code> 的区别是：</p>
<pre><code>- `while`：先判断，再执行。
- `do...while`：先执行，再判断。
</code></pre>
<ol>
<li><code>switch</code>语句</li>
</ol>
<p>适合选项</p>
<pre><code>switch(表达式){
    case 常量值1:{
        语句1;
        [break;]
    }
    case 常量值2:{
        语句2;
        [break;]
    }
    ...
    default:{
        内容都不满足时执行语句;
        [break;]
    }
}
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;多个&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;case&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;后的常量值不可以重复 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;switch&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;的括号内只能是以下类型的表达式： &lt;/font&gt;</p>
<ul>
<li>&lt;font style="color:rgb(51,51,51);"&gt;基本类型：byte、char、short、int，注意不能是long类型 &lt;/font&gt;</li>
<li>&lt;font style="color:rgb(51,51,51);"&gt;引用类型：String常量串、枚举类型&lt;/font&gt;</li>
</ul>
<h2>第六节.数组（Array）</h2>
<ol>
<li>什么是数组？</li>
</ol>
<ul>
<li>数组就是 <strong>一组相同类型的数据</strong> 的集合。</li>
<li>它可以用一个名字存放多个数据，并通过 <strong>下标（索引）</strong> 来访问。</li>
</ul>
<p>例子：</p>
<pre><code>int[] scores = {95, 88, 76, 100};
System.out.println(scores[0]); // 输出第一个元素 95
System.out.println(scores[3]); // 输出第四个元素 100
</code></pre>
<p>👉 注意：数组的下标是 <strong>从 0 开始</strong> 的。</p>
<ol>
<li>数组的声明与初始化</li>
</ol>
<p>&lt;font style="color:rgb(51,51,51);"&gt;数组的初始化主要分为&lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;动态初始化以及静态初始化&lt;/font&gt;</strong>&lt;font style="color:rgb(51,51,51);"&gt;。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;1. &lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;动态初始化：在创建数组时，直接指定数组中元素的个数 &lt;/font&gt;</strong></p>
<pre><code>int[] array = new int[10];
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;2. &lt;/font&gt;<strong>&lt;font style="color:rgb(51,51,51);"&gt;静态初始化：在创建数组时不直接指定数据元素个数，而直接将具体的数据内容进行指定 &lt;/font&gt;</strong></p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;语法格式： T[] 数组名称 = {data1, data2, data3, ..., datan};&lt;/font&gt;</p>
<pre><code>int[] array1 = new int[]{0,1,2,3,4,5,6,7,8,9};
double[] array2 = new double[]{1.0, 2.0, 3.0, 4.0, 5.0};
String[] array3 = new String[]{"hell", "Java", "!!!"};
</code></pre>
<p>&lt;font style="color:rgb(51,51,51);"&gt;【注意事项】 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;静态初始化虽然没有指定数组的长度，编译器在编译时会根据&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;{}&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;中元素个数来确定数组的长度。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;静态初始化时&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;, {}&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;中数据类型必须与&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;[]&lt;/font&gt;&lt;font style="color:rgb(51,51,51);"&gt;前数据类型一致。 &lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;静态初始化可以简写，省去后面的new T[]。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51,51,51);"&gt;3.数组初始化内容&lt;/font&gt;</p>
<table>
<thead>
<tr>
<th>类型</th>
<th>默认值</th>
</tr>
</thead>
<tbody>
<tr>
<td>byte</td>
<td>0</td>
</tr>
<tr>
<td>short</td>
<td>0</td>
</tr>
<tr>
<td>int</td>
<td>0</td>
</tr>
<tr>
<td>long</td>
<td>0</td>
</tr>
<tr>
<td>float</td>
<td>0.0f</td>
</tr>
<tr>
<td>double</td>
<td>0.0</td>
</tr>
<tr>
<td>char</td>
<td>/u0000</td>
</tr>
<tr>
<td>boolean</td>
<td>false</td>
</tr>
</tbody>
</table>
<ol>
<li>遍历数组</li>
</ol>
<p>用循环来访问数组的所有元素：</p>
<pre><code>int[] arr = {10, 20, 30, 40, 50};
for (int i = 0; i &lt; arr.length; i++) {
    System.out.println("第 " + (i+1) + " 个元素：" + arr[i]);
}
</code></pre>
<p>这里 <code>arr.length</code> 表示数组的长度。</p>
<p>5.二维数组</p>
<pre><code>数据类型[][] 数组名称 = new 数据类型 [行数][列数];
数据类型[][] 数组名称 = new 数据类型 [][] { 初始化数据 };
数据类型[][] 数组名称 = new 数据类型 [行数][];
</code></pre>
<p>补充对二维数组是一维数组的深度理解</p>
<pre><code>int[][] array ={{1,2,3},{4,5,6}};
System.out.println(array.length); //2
System.out.println(array[0].length);//3
System.out.println(Arrays.toString(array[1])); //[4, 5, 6]
String ret = System.out.println(Arrays.deepToString(array));
System.out.println(ret);	//[[1,2,3],[4,5,6]]
</code></pre>
<p>其中array的底层为图中所示</p>
<p><img src="./image.png" alt="" /></p>
<h3>数组补充内容</h3>
<h4>1. 找最大值和最小值</h4>
<p>思路：</p>
<ul>
<li>假设第一个元素是最大/最小值。</li>
<li>遍历数组，依次比较更新。</li>
</ul>
<pre><code>int[] arr = {12, 45, 7, 89, 23};
int max = arr[0];
int min = arr[0];

for (int i = 1; i &lt; arr.length; i++) {
    if (arr[i] &gt; max) {
        max = arr[i];
    }
    if (arr[i] &lt; min) {
        min = arr[i];
    }
}
System.out.println("最大值：" + max);
System.out.println("最小值：" + min);
</code></pre>
<h4>2. 倒序输出数组</h4>
<p>有时我们希望反过来输出：</p>
<pre><code>int[] arr = {1, 2, 3, 4, 5};
for (int i = arr.length - 1; i &gt;= 0; i--) {
    System.out.print(arr[i] + " ");
}
</code></pre>
<p>👉 这个思路也能用于「数组反转」。</p>
<h4>3. for each遍历（增强 for 循环）</h4>
<p>Java 提供了简化的遍历写法（但不能改变数组里的值）：</p>
<pre><code>int[] arr = {10, 20, 30};
for (int num : arr) {
    System.out.println(num);
}
</code></pre>
<h4>4. 多维数组（二维数组）</h4>
<p>二维数组 = 表格。<br />
比如存放学生成绩（3 个学生，每人 2 门课）：</p>
<pre><code>int[][] scores = {
    {90, 85},
    {70, 88},
    {100, 95}
};
System.out.println(scores[0][1]); // 第1个学生的第2门课成绩 85
</code></pre>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Java 语法基础（一）：标识符、数据类型与变量</title>
    <link href="https://blog.syrena.cn/posts/javase/javase/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/javase/javase/</id>
    <published>2025-09-03T00:00:00.000Z</published>
    <updated>2025-09-03T00:00:00.000Z</updated>
    <summary>初步学习Java语法.</summary>
    <content type="html"><![CDATA[<h2>第一节.标识符</h2>
<p><strong>即：在程序中由用户给类名、方法名或者变量所取的名字。</strong></p>
<p>有效字符：数字、字母、下划线、美元符号</p>
<p>其中&lt;span style="color:red"&gt;<strong>不能以数字开头，不能是关键字除（main），可以以下划线、美元符号开头</strong>&lt;/span&gt;</p>
<pre><code>public class Main {
    //psvm
    public static void main(String[] args) {
    }
}
</code></pre>
<p>注意：</p>
<p>1.java的数组的定义方式：int[] array</p>
<p>C语言数组的定义方式：int arr[] 其中int[]才为类型 ，因此java是修正过来了</p>
<p>2.在java当中，函数叫做方法。</p>
<h2>第二节.关键字</h2>
<table>
<thead>
<tr>
<th>用于定义访问权限修饰符的关键字</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>private</td>
<td>protected</td>
<td>public</td>
<td></td>
<td></td>
</tr>
<tr>
<td>用于定义类，函数，变量修饰符的关键字</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>abstract</td>
<td>final</td>
<td>static</td>
<td>synchronized</td>
<td></td>
</tr>
<tr>
<td>用于定义类与类之间关系的关键字</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>extends</td>
<td>implements</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>用于定义建立实例及引用实例，判断实例的关键字</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>new</td>
<td>this</td>
<td>super</td>
<td>instanceof</td>
<td></td>
</tr>
<tr>
<td>用于异常处理的关键字</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>try</td>
<td>catch</td>
<td>finally</td>
<td>throw</td>
<td>throws</td>
</tr>
<tr>
<td>用于包的关键字</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>package</td>
<td>import</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>其他修饰符关键字</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>native</td>
<td>strictfp</td>
<td>transient</td>
<td>volatile</td>
<td>assert</td>
</tr>
</tbody>
</table>
<h2>第三节.数据类型与变量</h2>
<h3>数据类型</h3>
<p><img src="./JavaSE.jpeg" alt="photo" /></p>
<table>
<thead>
<tr>
<th>类型</th>
<th>占用内存</th>
<th>例子</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>byte</code></td>
<td>1 字节</td>
<td><code>byte b = 100;</code></td>
<td>-128 ~ 127</td>
</tr>
<tr>
<td><code>short</code></td>
<td>2 字节</td>
<td><code>short s = 1000;</code></td>
<td>范围比 int 小</td>
</tr>
<tr>
<td><code>int</code></td>
<td>4 字节</td>
<td><code>int age = 20;</code></td>
<td><strong>最常用整数类型</strong></td>
</tr>
<tr>
<td><code>long</code></td>
<td>8 字节</td>
<td><code>long bigNum = 100000L;</code></td>
<td>末尾加 <code>L</code></td>
</tr>
<tr>
<td><code>float</code></td>
<td>4 字节</td>
<td><code>float pi = 3.14f;</code></td>
<td>小数，末尾加 <code>f</code></td>
</tr>
<tr>
<td><code>double</code></td>
<td>8 字节</td>
<td><code>double price = 19.99;</code></td>
<td><strong>最常用小数类型</strong></td>
</tr>
<tr>
<td><code>char</code></td>
<td>2 字节</td>
<td><code>char grade = 'A';</code></td>
<td>单个字符，用 <code>' '</code></td>
</tr>
<tr>
<td><code>boolean</code></td>
<td>1 位</td>
<td><code>boolean isOk = true;</code></td>
<td>只有 <code>true</code> 或 <code>false</code></td>
</tr>
</tbody>
</table>
<p>此处char与c++有所不同，c++为1个字节，并且java不区分32或64位操作系统</p>
<h3>变量</h3>
<p><strong>变量的命名规则</strong></p>
<ul>
<li>只能用 <strong>字母、数字、下划线、美元符号</strong>，且不能以数字开头</li>
<li>不能用 Java 关键字（如 <code>class</code>、<code>int</code>）</li>
<li>见名知意（推荐小驼峰命名法：<code>myName</code>）</li>
</ul>
<pre><code>double a = 1.1;
double b = 1.1;
System.out.println(a*b);
//1.2100000000000002
</code></pre>
<blockquote>
<p>此处和浮点数的存储有关系</p>
<p>float		double</p>
<p>精度：float 小数点后6位</p>
<p>1.123456789</p>
</blockquote>
<p><strong>浮点型变量</strong></p>
<p><strong>double注意事项：</strong></p>
<p>1.double在任何系统下都占8个字节</p>
<p>2.浮点数与整数在内存中的存储方式不同，不能单纯使用2n的形式来计算</p>
<p>3.double的包装类型为Double</p>
<p>4.double类型的内存布局遵守 IEEE 754 标准（和C语言一样），尝试使用有限的内存空间表示可能无限的小数，势必会存在一定的精度误差，英雌浮点数是个近似值，并不是精确值。</p>
<p><strong>float注意事项：</strong></p>
<p>1.float类型在java中占4个字节，同样遵循 IEEE 754 标准，由于表示的数据精度范围小，一般在工程上用到浮点数都优先考虑double，不太推荐使用float，float的包装类型为Float。</p>
<p><strong>字符型变量</strong></p>
<p><strong>char注意事项：</strong></p>
<p>1.java中使用<code>单引号 + 单个字母 </code>的形式表示字符字面值。</p>
<p>2.计算机中的字符本质上是一个整数，在&lt;span style="color:red"&gt;<strong>C语言中使用ASCll表示字符，而java中使用 Unicode 表示字符因此一个字符占用两个字节</strong>&lt;/span&gt;，表示的字符种类更多，包括中文。所以Unicode的字符集比ASCll要大</p>
<p><strong>布尔型变量</strong></p>
<p><strong>boolean注意事项：</strong></p>
<p>1.boolean 类型的变量只有两种取值，true表示真，false表示假</p>
<p>2.java 的 boolean 类型和int 不能相互转换，<strong>不存在</strong> 1表示true，0表示false 这样的用法。</p>
<p><strong>类型转换</strong></p>
<p>在java中，当参与运算数据类型不一致时，就会进行类型转换，java中类型转换主要分为两种：自动类型转换(隐式) 和 强制类型转换(显式)。</p>
<blockquote>
<p>自动类型转换（隐式）：</p>
<p>自动类型转换即：<strong>在代码编译时，编译器会自动进行处理</strong>；</p>
<p>特点：&lt;span style="color:red"&gt;<strong>数据范围小的转为数据范围大的时会自动进行</strong>。&lt;/span&gt;</p>
<p><strong>转换只发生在赋值的过程里</strong>，变量本身的类型不会改变。</p>
</blockquote>
<pre><code>//整数类型的范围关系是：
byte &lt; short &lt; int &lt; long &lt; float &lt; double

int a = 10;
long b = 10L;
b = a; 			//编译器会自动将int提升为long
b = (long) a;	//编译器自动帮你加上(long) 
</code></pre>
<p>强制类型转换（显式）：</p>
<p>强制类型转换：代码需要经过一定的格式处理，不能自动完成。</p>
<p>特点：数据范围大的到数据范围小的。</p>
<pre><code>float f = 3.14f;
double d = 3.14;
f =(float) d;	// float -&gt; double,数据范围由大到小，需要强转，否则编译失败

//补充：
//当你参与运算的数据 小于4个字节的很好 会进行提升
byte a = 10;
byte b = 20;
//byte c = (byte)(a+b) //因为a + b会自动提升类型为int，底层为避免溢出而设计
int c = a+b;
</code></pre>
<p>注意：</p>
<p>1.不相干的类型，不能进行强制类型转换。</p>
<p>2.将一个字面值常量进行赋值的时候，java 会自动针对数字范围进行检查。</p>
<p>补充：题目</p>
<pre><code>short a = 128;
byte b = (byte)a;
System.out.println(b); // -128
</code></pre>
<p>原因：Java 的整数都是 <strong>二进制补码存储</strong></p>
<ul>
<li><strong>short</strong> 占 <strong>16 位</strong>（2 字节），范围是 -32768 ~ 32767。</li>
<li><strong>byte</strong> 占 <strong>8 位</strong>（1 字节），范围是 -128 ~ 127。</li>
</ul>
<p>重点：当你从大类型（<code>short</code>）强制转换到小类型（<code>byte</code>）时，只会保留低 8 位，高位直接丢弃。</p>
<p>拆解过程：</p>
<p>Step 1：128 的二进制（short，16 位）</p>
<pre><code>0000 0000 1000 0000   （十六进制：0x0080）
</code></pre>
<p>Step 2：强制转换为 byte（取低 8 位）</p>
<pre><code>1000 0000
</code></pre>
<p>Step 3：解释这个 8 位二进制（byte 类型）</p>
<ul>
<li>在 Java 中，<code>byte</code> 是有符号的，范围是 -128 ~ 127。</li>
<li><code>1000 0000</code> 的最高位是 <strong>1</strong>，说明它是一个负数（补码形式）。</li>
</ul>
<p>补码 → 原码的过程：</p>
<pre><code>1000 0000 （补码）
= -128    （这是 byte 的最小值）
</code></pre>
<h3>字符串类型</h3>
<p>注意：</p>
<p>1.java当中的字符串不是以\0结尾的</p>
<p>2.当字符串和整数还在一起使用+号的意思 就是一个拼接的意思</p>
<p>eg:</p>
<pre><code>int a = 10,b = 20;
System.out.println("a:"+a + b);//a:1020
//原因：a:+a已经拼接表示为字符串了，因此该整体再与b之间也是拼接关系
</code></pre>
<p>部分情况下，需要将字符串和整型数字之间进行转换：</p>
<p>1.int 转成 String</p>
<pre><code>int num = 10;
//mean1
String str1 = num + "";
//mean2
String str2 = String.valueOf(num);
</code></pre>
<p>2.String 转成 int</p>
<pre><code>String str = "100";
int num = Integer.parseInt(str);
</code></pre>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Java SE"/>
  </entry>
  <entry>
    <title>Git 日常使用指南：版本回退、分支管理与远程仓库</title>
    <link href="https://blog.syrena.cn/posts/git-experience/git-experience/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/git-experience/git-experience/</id>
    <published>2025-08-27T00:00:00.000Z</published>
    <updated>2025-08-27T00:00:00.000Z</updated>
    <summary>一篇关于 Git 使用经验的文章.</summary>
    <content type="html"><![CDATA[<h2>📝以自己经验用Git</h2>
<h3>1.Git基础概念</h3>
<p>Git = word写文档	GitHub = 腾讯文档分享协作</p>
<p>Git三个区域</p>
<pre><code>工作区（Working Directory）—— 你正在编辑的文件
  ↓ git add
暂存区（Staging Area）—— 即将提交的快照
  ↓ git commit
版本库（Repository）—— Git 保存的历史记录
</code></pre>
<p>完整的开发流程通常是：</p>
<pre><code># 修改代码
git add 文件名             # 添加文件到暂存区
git commit -m "说明信息"   # 提交暂存区到版本库
</code></pre>
<hr />
<h3>2.本地 Git 仓库操作</h3>
<p>以下内容是指在终端中使用</p>
<pre><code>git config --global user.name "待填入名字"
git config -- global user.email "待填入邮箱"	#建议使用注册GitHub的用户名和邮箱，便于同步
                                  # --global 表示在你这台电脑上的所有仓库都使用这套设置。

git init #建立Git仓库，初始化Git仓库
         #或者从GitHub上拉一个项目（如刚创建了远程仓库）
git clone https://github.com/Sirens007/MyStorage.git

#如创建一个新文件
echo "#我的第一个项目" &gt; README.md
#添加到Git暂存区
git add README.md
#提交到本地仓库
git commit -m "添加了README文件"
#绑定 GitHub 仓库作为远程仓库 origin	（即origin为远程仓库昵称）
git remote add origin https://github.com/Sirens007/MyStorage.git
#首次推送GitHub
git push -u origin main #如果提示 main 不存在，可以先用 git branch -M main 把默认的 master 分支改名。

git status
#查看当前工作状态 （如查看还未暂存的文件
git log
#查看提交历史

</code></pre>
<hr />
<h3>3.查看状态与差异</h3>
<p>当你已经完成了基本的 <code>add</code> 和 <code>commit</code> 操作后，日常开发中你最常用的两个命令是：</p>
<ul>
<li><code>git status</code>：查看当前仓库状态</li>
<li><code>git diff</code>：查看文件内容发生了哪些改动</li>
</ul>
<p><strong>一、git status —— 查看状态</strong></p>
<pre><code>git status
</code></pre>
<p><strong>作用：</strong></p>
<p>查看当前工作区和暂存区的状态。</p>
<p><strong>原理解释：</strong></p>
<p>Git 会比较这三者的内容是否一致：</p>
<ol>
<li><strong>工作目录中的文件</strong></li>
<li><strong>暂存区中的文件（通过 git add 添加的）</strong></li>
<li><strong>最后一次提交的版本（历史）</strong></li>
</ol>
<p>&lt;details class="lake-collapse"&gt;
&lt;summary id="u29606243"&gt;&lt;strong&gt;&lt;span class="ne-text"&gt;结果分析&lt;/span&gt;&lt;/strong&gt;&lt;/summary&gt;</p>
<p>&lt;h4 id="98be1ecf"&gt;&lt;span class="ne-text" style="color: #DF2A3F"&gt;🔴&lt;/span&gt; &lt;span class="ne-text"&gt;情况1：工作区干净&lt;/span&gt;&lt;/h4&gt;
&lt;pre data-language="bash" id="WQi54" class="ne-codeblock language-bash"&gt;&lt;code&gt;$ git status
On branch master
nothing to commit, working tree clean&lt;/code&gt;&lt;/pre&gt;
&lt;p id="u0e2b9816" class="ne-p"&gt;&lt;span class="ne-text"&gt;意思是：没有文件被修改，也没有新增或删除。&lt;/span&gt;&lt;/p&gt;</p>
<p>&lt;h4 id="3e1a3f71"&gt;&lt;span class="ne-text" style="color: #DF2A3F"&gt;🟡&lt;/span&gt; &lt;span class="ne-text"&gt;情况2：修改了文件但还没 add&lt;/span&gt;&lt;/h4&gt;
&lt;pre data-language="bash" id="ZGtno" class="ne-codeblock language-bash"&gt;&lt;code&gt;$ git status
modified:   hello.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p id="ufa272c14" class="ne-p"&gt;&lt;span class="ne-text"&gt;说明 &lt;/span&gt;&lt;code class="ne-code"&gt;&lt;span class="ne-text"&gt;hello.txt&lt;/span&gt;&lt;/code&gt;&lt;span class="ne-text"&gt; 有改动，但还没有被添加进暂存区。&lt;/span&gt;&lt;/p&gt;</p>
<p>&lt;h4 id="c160fd94"&gt;&lt;span class="ne-text"&gt;🟢&lt;/span&gt; &lt;span class="ne-text"&gt;情况3：已 add 到暂存区但还没 commit&lt;/span&gt;&lt;/h4&gt;
&lt;pre data-language="bash" id="L2q4R" class="ne-codeblock language-bash"&gt;&lt;code&gt;$ git status
Changes to be committed:
(use "git restore --staged &lt;file&gt;..." to unstage)
modified:   hello.txt&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;</p>
<p><strong>二、git diff —— 查看修改内容</strong></p>
<pre><code>git diff
</code></pre>
<p><strong>作用</strong></p>
<ul>
<li>显示当前工作目录中未加入暂存区的变更（对比的是：工作区 ↔ 暂存区）</li>
</ul>
<p><strong>你也可以用：</strong></p>
<pre><code>git diff hello.txt
</code></pre>
<p>来查看某个具体文件的改动。</p>
<p>&lt;details class="lake-collapse"&gt;&lt;summary id="u423a2fc5"&gt;&lt;strong&gt;&lt;span class="ne-text"&gt;补充&lt;/span&gt;&lt;/strong&gt;&lt;/summary&gt;&lt;h4 id="k8LQy"&gt;&lt;span class="ne-text"&gt;查看已经 add 但还未 commit 的差异：&lt;/span&gt;&lt;/h4&gt;&lt;pre data-language="bash" id="J3Z0b" class="ne-codeblock language-git"&gt;&lt;code&gt;git diff --cached&lt;/code&gt;&lt;/pre&gt;&lt;p id="u52bf5124" class="ne-p"&gt;&lt;span class="ne-text"&gt;或者：&lt;/span&gt;&lt;/p&gt;&lt;pre data-language="bash" id="cEllX" class="ne-codeblock language-git"&gt;&lt;code&gt;git diff --staged&lt;/code&gt;&lt;/pre&gt;&lt;h4 id="u539c"&gt;&lt;span class="ne-text"&gt;&lt;/span&gt;&lt;span class="ne-text"&gt;作用：&lt;/span&gt;&lt;/h4&gt;&lt;ul class="ne-ul"&gt;&lt;li id="udf0dfced" data-lake-index-type="0"&gt;&lt;span class="ne-text"&gt;查看“暂存区”中与“上一次提交版本”之间的差异。&lt;/span&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/details&gt;</p>
<h3>4.文件恢复与版本回退</h3>
<p><strong>开发中你经常会遇到这些情况：</strong></p>
<table>
<thead>
<tr>
<th><strong>场景</strong></th>
<th><strong>想做的事</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>改错文件了</td>
<td>想恢复回之前保存的版本</td>
</tr>
<tr>
<td>add 错文件了</td>
<td>想撤回到未暂存状态</td>
</tr>
<tr>
<td>commit 之后后悔了</td>
<td>想撤销或回退提交</td>
</tr>
<tr>
<td>改了很多但不想要了</td>
<td>想恢复整个文件夹到之前状</td>
</tr>
</tbody>
</table>
<p><strong>本节主要命令：</strong></p>
<ul>
<li><code>git restore</code>：恢复文件内容（推荐）</li>
<li><code>git checkout</code>：旧版本恢复（老派用法）</li>
<li><code>git reset</code>：取消暂存、版本回退</li>
<li><code>git log</code>：查看历史提交</li>
</ul>
<hr />
<p><strong>一、恢复工作区文件：</strong><code>git restore</code></p>
<pre><code>git restore &lt;filename&gt;

eg:
echo "bad line" &gt;&gt; hello.txt      # 加了一行错误内容
git restore hello.txt             # 撤销这次改动
</code></pre>
<p><strong>作用</strong>：</p>
<p>把某个文件恢复到上一次 <code>git add</code> 或 <code>git commit</code> 之后的状态，<strong>取消你刚刚改的内容</strong>。</p>
<p><strong>背后原理：</strong></p>
<p>从 Git 暂存区或仓库中拿出该文件的版本，覆盖当前工作区。</p>
<hr />
<p><strong>二、撤销 <strong><code>git add</code></strong>：</strong><code>git restore --staged</code></p>
<pre><code>git restore --staged &lt;filename&gt;

eg:
git add hello.txt
git restore --staged hello.txt  #撤回add，但改动还在
</code></pre>
<p><strong>作用：</strong></p>
<p>把文件从<strong>暂存区</strong>移除，但保留工作区的改动。</p>
<p><strong>使用场景：</strong></p>
<p>你 <code>git add</code> 了一个不该提交的文件，可以用这条命令撤回。</p>
<hr />
<p><strong>三、版本回退：</strong><code>git reset</code></p>
<p><strong>命令：取消已经 add 的内容</strong></p>
<pre><code>git reset filename
</code></pre>
<p>等效于上面那句 <code>git restore --staged</code>，只是老一点的写法。</p>
<hr />
<p><strong>命令：回退到上一个提交版本（慎用）</strong></p>
<pre><code>git reset --hard HEAD
</code></pre>
<p><strong>作用：</strong></p>
<p>把整个项目（工作区 + 暂存区）恢复到上一次提交的状态。</p>
<p>❗❗ 注意：<code>--hard</code> 是“硬回退”，<strong>不可恢复的清空当前修改</strong>，一定要确认再用！</p>
<hr />
<p><strong>四、查看历史版本：</strong><code>git log</code></p>
<ul>
<li>显示提交历史</li>
<li>会看到一堆 <code>commit xxx...</code> 的 ID、作者、时间、提交说明</li>
</ul>
<pre><code>git log

eg:
git log -p hello.txt	#查看历史内容改了什么
</code></pre>
<h3>5.分支管理（分支的创建、切换、合并）</h3>
<p>在实际开发中，我们经常会遇到这种需求：</p>
<ul>
<li>不打扰主线代码，先试试一个新功能</li>
<li>多个开发人员各自写各自的功能</li>
<li>想在多个版本之间切换工作</li>
</ul>
<p>这时，就要用到「<strong>分支（branch）</strong>」来隔离、管理不同的开发线。</p>
<blockquote>
<p>&lt;font style="color:#DF2A3F;"&gt;补充对分支的理解，如我在main分支创建了a.txt （未add），此时创建dev分支可以看见main分支对a.txt的更改。但如果我在dev上commit之后，回到main之后则不存在a.txt文件了，就相当于两个工作区一样&lt;/font&gt;</p>
</blockquote>
<p><strong>Git 分支基本概念</strong></p>
<ul>
<li><strong>主分支</strong>：默认叫 <code>master</code>（或 <code>main</code>）</li>
<li><strong>分支的本质</strong>：一个指向某次提交的“指针”</li>
<li><strong>HEAD</strong>：指向当前分支的位置（你正在干活的分支）</li>
</ul>
<p><strong>1.查看已有分支：</strong> <code>git branch 分支名</code></p>
<pre><code>git branch
</code></pre>
<ul>
<li>会列出当前所有分支，当前所在的分之前有<code>*</code>标记</li>
</ul>
<hr />
<p><strong>2.创建新分支：</strong><code>git branch 分支名</code></p>
<pre><code>git branch dev
</code></pre>
<ul>
<li>创建一个名为dev的新分支，复制自当前分支的最新提交。</li>
</ul>
<hr />
<p><strong>3.切换分支：</strong><code>git switch 分支名</code></p>
<pre><code>git switch dev
</code></pre>
<ul>
<li>此时已切换dev分支，开始在上面开发了</li>
<li>如果是老版本Git，也可以用<code>git checkout dev</code>。</li>
</ul>
<hr />
<p><strong>4.创建并切换新分支：一条命令搞定</strong></p>
<pre><code>git switch -c dev
</code></pre>
<p>相当于：</p>
<pre><code>git branch dev
git switch dev
</code></pre>
<p>Git 会尝试把<code>dev</code>上的改动合并进master，如果没有冲突，会自动完成。</p>
<hr />
<p>5.合并分支：<code>git merge</code></p>
<p>切换会主分支后，合并新分支的内容：</p>
<pre><code>git switch main
git merge dev
</code></pre>
<p>Git 会尝试把<code>dev</code>上的改动合并进main，如果没有冲突，会自动完成。</p>
<hr />
<p>6.删除分支（合并后清理）：<code>git branch -d</code></p>
<pre><code>git branch -d dev
</code></pre>
<p>如果你确认合并完了，就可以删掉旧分支。</p>
<h3>6.搭建ssh远程连接（无需梯子）</h3>
<p><strong>一、配置git bash</strong></p>
<p>在git bash中运行以下：</p>
<pre><code>ssh-keygen -t ed25519 -C "你的GitHub邮箱"
</code></pre>
<p>当提示：</p>
<pre><code>Enter file in which to save the key (~/.ssh/id_ed25519):
</code></pre>
<p>你可以直接回车（会覆盖旧的 id_ed25519）<br />
或者输入一个新名字，比如：</p>
<pre><code>~/.ssh/id_ed25519_laptop
</code></pre>
<hr />
<p><strong>二、启用新的ssh key</strong></p>
<pre><code>eval "$(ssh-agent -s)"

ssh-add ~/.ssh/id_ed25519_laptop
</code></pre>
<p><strong>三、把公钥加到Github</strong></p>
<pre><code>cat ~/.ssh/id_ed25519_laptop.pub
</code></pre>
<p>复制整段（从 <code>ssh-ed25519</code> 开始到最后），<br />
然后在 GitHub → <strong>Settings</strong> → <strong>SSH and GPG keys</strong> → <strong>New SSH key</strong> → 粘贴保存。<br />
标题建议写成 “Laptop - 2025” 这种好区分的名字。</p>
<p><strong>四、验证</strong></p>
<pre><code>ssh -T git@github.com
</code></pre>
<p>看到：</p>
<pre><code>Hi Sirens007! You've successfully authenticated...
</code></pre>
<p>说明配置成功</p>
<h3>7.远程仓库操作</h3>
<p><strong>1.远程仓库概念</strong></p>
<p>本地 Git 仓库 = 你电脑上的版本库（<code>.git</code> 目录）。<br />
远程仓库 = 存在于服务器（比如 GitHub）上的版本库。</p>
<p><strong>2.四个常用操作</strong></p>
<p><strong>（1）clone - 从远程仓库下载到本地</strong></p>
<pre><code>git clone https://github.com/Sirens007/MyStorage.git

#运行后，你本地就有一个 myproject 目录，并且可以直接 git pull 或 git push。
</code></pre>
<p>作用：</p>
<ul>
<li>会在本地新建一个文件夹</li>
<li>自动把该仓库的所有版本历史下载到本地</li>
<li>自动设置好 <code>origin</code> 这个远程名，指向该仓库</li>
</ul>
<hr />
<p><strong>（2）remote - 管理远程仓库地址</strong></p>
<p>查看已有远程：</p>
<pre><code>git remote -v
</code></pre>
<p>添加远程：</p>
<pre><code>git remote add origin https://github.com/Sirens007/MyStorage.git
</code></pre>
<p>修改远程：</p>
<pre><code>git remote set-url origin &lt;新地址&gt;
</code></pre>
<p>删除远程：</p>
<pre><code>git remote remove origin
</code></pre>
<p><code>origin</code> 是默认的远程仓库名字，你也可以用别的名字。</p>
<hr />
<p><strong>（3）push - 推送本地代码到远程</strong></p>
<p>第一次推送（如果远程没有内容）：</p>
<pre><code>git push -u origin main
</code></pre>
<p>解释：</p>
<ul>
<li><code>origin</code>：远程仓库名</li>
<li><code>main</code>：分支名</li>
<li><code>-u</code>：记住这个推送目标，下次可以直接<code>git push</code></li>
</ul>
<p>之后推送就可以：</p>
<pre><code>git push
</code></pre>
<hr />
<p><strong>（4）pull - 从远程拉取最新版本</strong></p>
<pre><code>git pull origin main
</code></pre>
<p>作用：</p>
<ul>
<li>把远程 main 分支的最新提交下载到本地</li>
<li>并与本地当前分支合并</li>
</ul>
<p>如果远程没有变化，你会看到：</p>
<pre><code>Already up to date.
</code></pre>
<hr />
<p><strong>（5）远程分支与本地分支的关系</strong></p>
<ul>
<li>当你执行 <code>git clone</code> 或 <code>git fetch</code> 后，Git 会在本地维护远程分支的快照，叫做 <strong>远程跟踪分支</strong>，格式一般是 <code>origin/分支名</code>。</li>
<li>本地分支和远程分支是分开的，推送（<code>push</code>）和拉取（<code>pull</code>）就是它们之间的同步操作。</li>
</ul>
<hr />
<p><strong>（6）查看远程分支</strong></p>
<pre><code>git branch -r
</code></pre>
<ul>
<li>只列出远程仓库的分支</li>
</ul>
<pre><code>git branch -a
</code></pre>
<ul>
<li>本地和远程所有分支都会显示</li>
</ul>
<hr />
<p><strong>（7）跟踪远程分支的新建本地分支</strong></p>
<p>如果远程有个分支，你想在本地工作，需要先新建一个本地分支跟踪远程：</p>
<pre><code>git switch-c dev origin/dev
</code></pre>
<ul>
<li>这会基于远程<code>dev</code>分支新建本地<code>dev</code>分支</li>
</ul>
<hr />
<p><strong>（8）远程仓库冲突解决</strong></p>
<p>多人合作时，推送可能被拒绝，提示远程有更新需要先拉取：</p>
<pre><code>git push
To https://github.com/xxx.git
 ! [rejected]        main -&gt; main (fetch first)
error: failed to push some refs to 'https://github.com/xxx.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. ...
</code></pre>
<p>这时，需要先拉取远程的最新修改：</p>
<pre><code>git pull --rebase origin main
</code></pre>
<ul>
<li><code>--rebase</code> 表示把你的提交“挪到”最新远程修改之后，避免额外合并提交。</li>
</ul>
<hr />
<p><strong>（9）查看远程仓库详情</strong></p>
<pre><code>git remote show origin
</code></pre>
<p>可以看到：</p>
<ul>
<li>远程分支状态</li>
<li>你本地分支对应的上游分支</li>
<li>是否有可推送或拉取的内容</li>
</ul>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Git"/>
  </entry>
  <entry>
    <title>Mizuki 博客部署指南：本地构建与 GitHub Pages 发布</title>
    <link href="https://blog.syrena.cn/posts/github-pages/github-pages/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/github-pages/github-pages/</id>
    <published>2025-08-27T00:00:00.000Z</published>
    <updated>2025-08-27T00:00:00.000Z</updated>
    <summary>一篇关于建立GitHub Pages博客的文章.</summary>
    <content type="html"><![CDATA[<h1>环境依赖</h1>
<p>在开始使用 Mizuki 之前，您需要确保系统满足以下要求：</p>
<ul>
<li><strong>Node.js &gt;= 20</strong></li>
<li><strong>pnpm &gt;= 9</strong></li>
<li><strong>Git</strong></li>
</ul>
<h2>安装Node.js</h2>
<p>访问 <a href="https://nodejs.org/">Node.js 官网</a> 下载并安装最新版本的 Node.js。建议使用 LTS 版本。</p>
<p>安装完成后，打开终端或命令提示符，运行以下命令验证 Node.js 是否安装成功：</p>
<pre><code>node -v
npm -v
</code></pre>
<p>如果显示版本号，则表示安装成功。</p>
<h2>安装pnpm</h2>
<p>如果您尚未安装 pnpm，可以通过 npm 安装：</p>
<pre><code>npm install -g pnpm
</code></pre>
<p>安装完成后，打开终端或命令提示符，运行以下命令验证 pnpm 是否安装成功：</p>
<pre><code>pnpm -v
</code></pre>
<p>如果显示版本号，则表示安装成功。</p>
<h2>安装Git</h2>
<p>访问 <a href="https://git-scm.com/downloads">Git 官网</a> 下载并安装适合您操作系统的 Git 版本。</p>
<p>安装完成后，打开终端或命令提示符，运行以下命令验证 Git 是否安装成功：</p>
<pre><code>git --version
</code></pre>
<p>如果显示版本号，则表示安装成功。</p>
<h1>项目启动</h1>
<h2>1.克隆项目</h2>
<p>首先将模板拉下到本地仓库</p>
<pre><code>git clone https://github.com/matsuzaka-yuki/Mizuki.git
cd ...//cd到项目地址处
</code></pre>
<h2>2.安装依赖</h2>
<p>使用 pnpm 安装项目依赖：</p>
<pre><code>pnpm install
</code></pre>
<h2>3.配置博客</h2>
<p>在启动项目之前，您需要根据自己的需求进行配置：</p>
<ul>
<li>编辑 <code>src/config.ts</code> 文件来自定义博客设置</li>
<li>更新站点信息、主题颜色、横幅图片和社交链接</li>
<li>配置翻译设置和特殊页面功能</li>
</ul>
<h2>4.启动开发服务器</h2>
<p>运行以下命令启动开发服务器：</p>
<pre><code>pnpm dev
</code></pre>
<p>启动成功后，您可以在浏览器中访问 <code>http://localhost:4321</code> 查看您的博客。</p>
<h2>5.打包网站</h2>
<p>在本地项目处运行以下命令将网站打包成静态文件，生成到 <code>dist</code> 目录中：</p>
<pre><code>pnpm build
</code></pre>
<p>生成的 <code>dist</code> 目录可以部署到您自己的服务器上。</p>
<h1>部署到GitHub Pages</h1>
<p>如果您希望将博客托管在 GitHub Pages 上，Mizuki 项目通常会包含一个 GitHub Actions 工作流，可以帮助您自动化部署过程。您需要确保在 <code>astro.config.mjs</code> 中配置正确的 <code>base</code> 路径。</p>
<p>还有将<code>package.json</code>的script中加入</p>
<pre><code>{
  "scripts": {
    "build": "vite build",  
    "deploy": "gh-pages -d dist -r https://github.com/&lt;你的用户名&gt;/&lt;你的仓库名&gt;.git"
  }
}
</code></pre>
<p>总共要改的配置文件为<code>astro.config.mjs</code>、<code>package.json</code>、<code>src/config.ts</code></p>
<h2>部署到 github.io 网址</h2>
<p>在 <code>astro.config.mjs</code> 中配置文件设置 <code>site</code> 和 <code>base</code> 选项。</p>
<pre><code>import { defineConfig } from 'astro/config'

export default defineConfig({
  site: 'https://sirens007.github.io/',
  base: '/',
})
</code></pre>
<p><strong>Site</strong></p>
<p><code>site</code> 的值必须是以下之一：</p>
<ul>
<li>基于你的用户名的以下网址: <code>https://&lt;username&gt;.github.io</code></li>
<li>为 GitHub 组织的私有页面 自动生成的随机网址：<code>https://&lt;random-string&gt;.pages.github.io/</code></li>
</ul>
<blockquote>
<p><strong>提示</strong></p>
<p>如果出现了以下情况，则不要设置 base 参数：</p>
<p>你的页面是由根文件夹所提供。</p>
<p>你的源码存储库是位于 https://github.com/&lt;USERNAME&gt;/&lt;USERNAME&gt;.github.io。</p>
</blockquote>
<p><strong>Base</strong></p>
<p><code>base</code> 的值应该是你的仓库名称，以正斜杠开头，例如 <code>/my-blog</code>。这样做是为了让 Astro 理解你的网站根目录是 <code>/my-repo</code>，而不是默认的 <code>/</code>。</p>
<p><strong>注意</strong></p>
<p>当配置了这个值后，你所有的内部页面链接都必须以你的 base 值作为前缀：</p>
<pre><code>&lt;a href="/my-repo/about"&gt;关于本站&lt;/a&gt;
</code></pre>
<p>查看更多关于配置 <a href="https://docs.astro.build/zh-cn/reference/configuration-reference/#base">base</a> 值的信息。</p>
<h2>（可选）在GitHub Pages上使用自定义域名</h2>
<blockquote>
<p><strong>设置一个自定义域</strong></p>
<p>你可以选择通过将一个 ./public/CNAME 文件添加到你的项目中，来设置自定义域</p>
<p>这会将你的站点部署在你的自定义域而不是 &lt;YOUR_USERNAME&gt;.github.io。</p>
<p>不要忘记为你的域名提供商配置 DNS。</p>
</blockquote>
<p>要配置 Astro 以在 GitHub Pages 上使用自定义域名，请将你的域名设置为 site 的值。不要为 <code>base</code> 设置值：</p>
<pre><code>import { defineConfig } from 'astro/config'

export default defineConfig({
    site: 'https://example.com',
})
</code></pre>
<h2>方法一：配置GitHub Action</h2>
<p>在你的项目中的 <code>.github/workflows/</code> 目录创建一个新文件 <code>deploy.yml</code>，并粘贴以下 YAML 配置信息。</p>
<pre><code>name: Deploy to GitHub Pages

on:
# 每次推送到 `main` 分支时触发这个“工作流程”
# 如果你使用了别的分支名，请按需将 `main` 替换成你的分支名
push:
    branches: [ main ]
# 允许你在 GitHub 上的 Actions 标签中手动触发此“工作流程”
workflow_dispatch:

# 允许 job 克隆 repo 并创建一个 page deployment
permissions:
contents: read
pages: write
id-token: write

jobs:
build:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout your repository using git
        uses: actions/checkout@v4
    - name: Install, build, and upload your site
        uses: withastro/action@v3
        # with:
        # path: . # 存储库中 Astro 项目的根位置。（可选）
        # node-version: 20 # 用于构建站点的特定 Node.js 版本，默认为 20。（可选）
        # package-manager: pnpm@latest # 应使用哪个 Node.js 包管理器来安装依赖项和构建站点。会根据存储库中的 lockfile 自动检测。（可选）

deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
    name: github-pages
    url: ${{ steps.deployment.outputs.page_url }}
    steps:
    - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
</code></pre>
<p>（可选）如果你在本地开发期间或预览构建期间，将环境变量传入给 Astro 项目，则需要定义 deploy.yml 文件中的任何公共变量，以便在部署到 Github 页面时处理它们。</p>
<pre><code>jobs:
build:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout your repository using git
        uses: actions/checkout@v4
    - name: Install, build, and upload your site
        uses: withastro/action@v3
        env:
        # 使用单引号来包裹变量值
        PUBLIC_EVM_WALLET_ADDRESS: '0x4bFc229A40d41698154336aFF864f61083E76659'
</code></pre>
<ol>
<li>在 GitHub 上，跳转到存储库的 Settings 选项卡并找到设置的 Pages 部分。</li>
<li>选择 GitHub Actions 作为你网站的 Source，然后按 Save。</li>
<li>选择 GitHub Actions 作为你网站的 Source，然后按 Save。</li>
</ol>
<p>你的网站现在应该已完成发布了！当你将更改推送到 Astro 项目的存储库时，GitHub Action 将自动为你部署它们。</p>
<h2>方法二：增加gh-pages分支</h2>
<h2>安装部署</h2>
<pre><code>pnpm add -D gh-pages
</code></pre>
<p>安装 <code>gh-pages</code> 包，它会帮你把 <code>dist/</code> 目录推送到 GitHub 仓库的 <code>gh-pages</code> （自动创建）分支。</p>
<p><strong>运行命令</strong></p>
<ul>
<li><code>pnpm run build</code> → 生成 <code>dist/</code></li>
<li><code>pnpm run deploy</code> → 自动把 <code>dist/</code> 上传到 GitHub Pages</li>
</ul>
<h1>日常维护</h1>
<h4>1.修改/新增文章或页面</h4>
<p>在本地项目里（比如 <code>src/content</code>、<code>posts</code>、<code>pages</code> 等目录），新建或修改 Markdown/HTML/JSX 文件。</p>
<h4>2. 重新构建</h4>
<pre><code>pnpm build
</code></pre>
<ul>
<li><strong>作用</strong>：把修改后的源代码再打包到 <code>dist/</code> 目录。</li>
<li><strong>原因</strong>：GitHub Pages 只会展示 <code>dist</code> 文件夹里的内容，而不会读取你的源代码。</li>
</ul>
<h4>3. 部署到 GitHub Pages</h4>
<pre><code>pnpm run deploy
</code></pre>
<ul>
<li><strong>作用</strong>：自动运行 <code>gh-pages -d dist</code>，把最新的 <code>dist/</code> 推送到 <code>gh-pages</code> 分支。</li>
<li><strong>原因</strong>：GitHub Pages 是根据 <code>gh-pages</code> 分支的内容来渲染你的网站的。</li>
</ul>
<p><a href="https://docs.mizuki.mysqil.com/guide/deployment/#%E9%85%8D%E7%BD%AE-github-action">部署 | Mizuki主题官方文档</a></p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="博客运维"/>
  </entry>
  <entry>
    <title>Git 协作工作流：分支、提交、推送与 Pull Request</title>
    <link href="https://blog.syrena.cn/posts/git-basic-commands/git-basic-commands/" rel="alternate" type="text/html"/>
    <id>https://blog.syrena.cn/posts/git-basic-commands/git-basic-commands/</id>
    <published>2025-08-20T00:00:00.000Z</published>
    <updated>2025-08-20T00:00:00.000Z</updated>
    <summary>一篇关于 Git基础命令的笔记.</summary>
    <content type="html"><![CDATA[<h2>📝Git工作流程</h2>
<p><img src="./photo1.png" alt="photo1" /></p>
<h3>1.克隆仓库</h3>
<p>如果你要参与一个已有的项目，通常会创建一个新的分支：</p>
<pre><code>git clone https://github.com/username/repo.git
cd repo
</code></pre>
<h3>2.创建新分支</h3>
<p>为了避免直接在main或master分支上进行开发，通常会创建一个新的分支：</p>
<pre><code>git checkout -b new-feature
</code></pre>
<h3>3.工作目录</h3>
<p>在工作目录中进行代码编辑、添加新文件或删除不需要的文件。</p>
<h3>4.暂存文件</h3>
<p>将修改的文件添加到暂存区，以便进行下一步的提交操作：</p>
<pre><code>git add filename
# 或者添加所有修改的文件
git add .
</code></pre>
<h3>5.提交更改</h3>
<p>将暂存区的更改提交到本地仓库，并添加提交信息：</p>
<pre><code>git commit -m "Add new feature"
</code></pre>
<h3>6.拉取最新更改</h3>
<p>推送本地更改之前，最好从远程仓库拉取最新的更改，以避免冲突：</p>
<pre><code>git pull origin main
# 或者如果在新的分支上工作
git pull origin new-feature
</code></pre>
<h3>7.推送更改</h3>
<p>将本地的提交推送到远程仓库：</p>
<pre><code>git push origin new-feature
</code></pre>
<h3>8.创建Pull Request（PR）</h3>
<p>在github或其他托管平台上创建Pull Request，邀请团队成员进行代码审查。PR合并后，你的更改就会合并到主分支。</p>
<h3>9.合并更改</h3>
<p>在PR审核通过并合并后，可以将远程仓库的主分支合并到本地分支：</p>
<pre><code>git checkout main
git pull origin main
git merge new-feature
</code></pre>
<h3>10.删除分支</h3>
<p>如果不需要新功能分支，可以将其删除：</p>
<pre><code>git branch -d new-feature
</code></pre>
<p>或者从远程仓库删除分支：</p>
<pre><code>git push origin --delete new-feature
</code></pre>
<h2>📝Git创建仓库</h2>
<h3>git init</h3>
<p>Git使用git init命令来初始化一个Git仓库，Git的很多命令都需要在Git的仓库中运行，所以git init是使用Git的第一个命令。</p>
<p>在执行完git init命令后，Git仓库会生成一个.git目录，该目录包含了资源的所有元数据，其他的项目目录保持不变。</p>
<p><strong>使用方法</strong></p>
<p>进入想要创建仓库的目录，或者先创建一个新的目录：</p>
<pre><code>mkdir my-project
cd my-project
</code></pre>
<p>使用当前目录作为Git仓库，我们只需使用它初始化。</p>
<pre><code>git init
</code></pre>
<p>该命令执行完成后会在当前目录生成一个.git目录。</p>
<p>使用我们指定目录作为Git仓库。</p>
<pre><code>git init newrepo
</code></pre>
<p>初始化后，会在newrepo目录下会出现一个名为.git的目录，所有Git需要的数据和资源都存放在这个目录中。</p>
<p>如果当前目录下有几个文件想要纳入版本控制，需要先使用git add 命令告诉Git开始对这些文件进行跟踪，然后提交：</p>
<pre><code>git add *.c 	 #将所有.c文件纳入版本控制
git add README #添加README文件（一般为项目说明文档），可以是README.md、README.txt
git commit -m '初始化项目版本'  #
</code></pre>
<p>以上命令将目录一下以 .c 结尾及README文件提交到仓库中。</p>
<blockquote>
<p><strong>注：</strong> 在Linux系统中，commit信息使用单引号 ' ，Windows系统，commit信息使用双引号"。</p>
<p>所以在git bash中 git commit -m '提交说明' 这样是可以的，在Windows命令行中就要使用双引号 git commit -m "提交说明"。</p>
</blockquote>
<h3>git clone</h3>
<p>我们使用git clone 从现有的Git仓库中拷贝项目（类似svn checkout）。</p>
<p>克隆仓库的命名格式为：</p>
<pre><code>git clone &lt;repo&gt;
</code></pre>
<p>如果我们需要克隆到指定的目录，可以使用以下命令格式：</p>
<pre><code>git clone &lt;repo&gt; &lt;directory&gt;
</code></pre>
<p>参数说明：</p>
<ul>
<li>repo:Git仓库</li>
<li>directory:本地仓库</li>
</ul>
<p>比如，要克隆Ruby语言的Git代码仓库Grit，可以用下面的命令：</p>
<pre><code>git clone git://github.com/Sirens007/MyStorage.git Mystorage
#git:为Git专属协议，类似https

#git clone git://github.com/Sirens007/MyStorage.git 是仓库地址，指向Github上名为MyStorage的仓库
</code></pre>
<p><strong>配置</strong></p>
<p>git的设置使用 git config 命令</p>
<p>显示当前的git配置信息：</p>
<pre><code>$ git config --list
#输出以下信息
credential.helper=osxkeychain
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true
core.ignorecase=true
core.precomposeunicode=true
</code></pre>
<p>编辑git配置文件：</p>
<pre><code>git config -e	#针对当前仓库
</code></pre>
<p>或者：</p>
<pre><code>git config -e --global	#针对系统上所有仓库
</code></pre>
<p>设置提交代码时的用户信息：</p>
<pre><code>git config --global user.name "Sirens007"
git config --global user.email "2921646312@qq.com"
</code></pre>
<p>如果去掉 --global参数支队当前仓库有效。</p>
<h2>Git基本操作</h2>
<p>&lt;font style="color:rgb(51, 51, 51);"&gt;Git 的工作就是创建和保存你项目的快照及与之后的快照进行对比。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51, 51, 51);"&gt;本章将对有关创建与提交你的项目快照的命令作介绍。&lt;/font&gt;</p>
<p>&lt;font style="color:rgb(51, 51, 51);"&gt;Git常用的是以下6个命令：git clone、git push、git add、git commit、git checkout、git pull，之后会详细介绍&lt;/font&gt;</p>
<p><img src="./photo2.jpg" alt="" /></p>
<p>说明：</p>
<ul>
<li>workplace：工作区</li>
<li>staging area：暂存区/缓存区</li>
<li>local repository：版本库或本地仓库</li>
<li>remote  respository：远程仓库</li>
</ul>
<p>一个简单的操作步骤：</p>
<pre><code>git init
git add
git commit
</code></pre>
<ul>
<li>git init - 初始化仓库。</li>
<li>git add - 添加文件到暂存区。</li>
<li>git commit - 将暂存区内容添加到仓库中。</li>
</ul>
<p><strong>创建仓库命令</strong></p>
<p>git创建仓库的命令：</p>
<table>
<thead>
<tr>
<th>命令</th>
<th>说明</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>git init</td>
<td>初始化仓库</td>
<td></td>
</tr>
<tr>
<td>git clone</td>
<td>拷贝一份远程仓库，也就是下载一个项目</td>
<td></td>
</tr>
</tbody>
</table>
<h3>提交与修改</h3>
<p>Git的工作就是创建和保存你的项目的快照及与之后的快照进行对比。</p>
<p>下表列出有关创建与提交你的项目的快照的命令：</p>
<table>
<thead>
<tr>
<th>命令</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>git add</td>
<td>添加文件到暂存区</td>
</tr>
<tr>
<td>git status</td>
<td>查看仓库当前的状态，显示有变更的文件</td>
</tr>
<tr>
<td>git diff</td>
<td>比较文件的不同，即暂存区和工作区的差异</td>
</tr>
<tr>
<td>git difftool</td>
<td>使用外部差异工具查看和比较文件的更改</td>
</tr>
<tr>
<td>git range-diff</td>
<td>比较两个提交范围之间的差异</td>
</tr>
<tr>
<td>git commit</td>
<td>提交暂存区到本地仓库</td>
</tr>
<tr>
<td>git reset</td>
<td>回退版本</td>
</tr>
<tr>
<td>git rm</td>
<td>将文件从暂存区和工作区中删除</td>
</tr>
<tr>
<td>git mv</td>
<td>移动或重命名工作区文件</td>
</tr>
<tr>
<td>git notes</td>
<td>添加注释</td>
</tr>
<tr>
<td>git checkout</td>
<td>分支切换</td>
</tr>
<tr>
<td>git switch（Git 2.23版本）</td>
<td>更清晰地切换分支</td>
</tr>
<tr>
<td>git restore（Git 2.23版本）</td>
<td>恢复或撤销文件的更改</td>
</tr>
<tr>
<td>git show</td>
<td>显示Git对象的详细信息</td>
</tr>
</tbody>
</table>
<p><strong>提交日志</strong></p>
<table>
<thead>
<tr>
<th>命令</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>git log</td>
<td>查看历史提交记录</td>
</tr>
<tr>
<td>git blame &lt;file&gt;</td>
<td>以列表形式查看指定文件的历史修改记录</td>
</tr>
<tr>
<td>git shortlog</td>
<td>生成简洁的提交日志摘要</td>
</tr>
<tr>
<td>git describe</td>
<td>生成一个可读的字符串，该字符串基于Git的标签系统来描述当前的提交</td>
</tr>
</tbody>
</table>
<p><strong>远程操作</strong></p>
<table>
<thead>
<tr>
<th>命令</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>git remote</td>
<td>远程仓库操作</td>
</tr>
<tr>
<td>git fetch</td>
<td>从远程获取代码库</td>
</tr>
<tr>
<td>git pull</td>
<td>下载远程代码并合并</td>
</tr>
<tr>
<td>git push</td>
<td>上传远程代码并合并</td>
</tr>
<tr>
<td>git submodule</td>
<td>管理包含其他Git仓库的项目</td>
</tr>
</tbody>
</table>
<h3>Git文件状态</h3>
<p>Git的文件状态分为三种：工作目录（Working Directory）、暂存区（Staging Area）、本地仓库（Local Repository）。</p>
<p><strong>工作目录（Working Directory）</strong></p>
<p>工作目录是你在本地计算机上看到的项目文件。它是你实际操作文件的地方，包括查看、编辑、删除和创建文件。所有对文件的更改首先发生在工作目录中。</p>
<p>在工作目录中的文件可能有以下几种状态：</p>
<ul>
<li>未跟踪（Untracked）：新创建的文件，未被Git记录。</li>
<li>已修改（Modified）：已被Git跟踪的文件发生了更改，但这些更改还没有被提交到Git记录中。</li>
</ul>
<p><strong>暂存区（Staging Area）</strong></p>
<p>暂存区，也被称为索引（Index），是一个临时存储区域，用于保存即将提交到本地仓库的更改。你可以选择性地将工作目录中的更改添加到暂存区中，这样你就可以一次提交多个文件的更改，而不必提交所有文件的更改。</p>
<ul>
<li>使用git add &lt;filename&lt;filename&gt;&gt; 命令将文件从工作目录添加到暂存区。</li>
<li>使用git add . 命令将当前目录下的所有更改添加到暂存区。</li>
</ul>
<p><strong>本地仓库（Local Respository）</strong></p>
<p>本地仓库是一个隐藏在 .git 目录中的数据库，用于存储项目的所有提交历史记录。每次你提交更改时，Git会将暂存区中的内容保存到本地仓库中。</p>
<p>使用 git commit -m "commit message" 命令将暂存区中的更改提交到本地仓库。</p>
<p><strong>文件状态的转换流程</strong></p>
<p><strong>未跟踪（Untracked）：</strong> 新创建的文件最初是未跟踪的。它们存在于工作目录中，但没有被Git跟踪。</p>
<blockquote>
<p>git add  newfile.txt  	# 添加文件到暂存区</p>
<p>git status           	       # 查看状态，显示 newfile.txt 在暂存区</p>
</blockquote>
<p><strong>已跟踪（Tracked）：</strong>      通过git add 命令将未跟踪的文件添加到暂存区后，文件变为已跟踪状态。</p>
]]></content>
    <author><name>Syrena.</name></author>
    <category term="Git"/>
  </entry>
</feed>
