专业的JAVA编程教程与资源

网站首页 > java教程 正文

轻松掌握Java多线程 - 第六章:volatile关键字

temp10 2025-04-27 16:20:07 java教程 2 ℃ 0 评论
  • 学习目标
  • 1 内存可见性问题
  • 1.1 什么是内存可见性
  • 1.2 内存可见性问题的原因
  • 2 volatile的作用与使用场景
  • 2.1 volatile关键字介绍
  • 2.2 volatile的内存语义
  • 2.3 volatile的适用场景
  • 2.4 volatile不能解决的问题
  • 3 volatile与synchronized的区别
  • 3.1 可见性与原子性
  • 3.2 使用范围
  • 3.3 性能开销
  • 3.4 适用场景对比
  • 4 volatile的内部原理
  • 4.1 内存屏障(Memory Barrier)
  • 4.2 happens-before关系
  • 4.3 volatile的底层实现
  • 5 实战案例:使用volatile实现一个简单的缓存系统
  • 6 常见问题与解决方案
  • 6.1 volatile不保证原子性
  • 6.2 过度使用volatile
  • 6.3 volatile与单例模式
  • 6.4 volatile数组
  • 6.5 volatile的性能考量
  • 7 小结

学习目标

  • 理解Java内存模型与内存可见性问题
  • 掌握volatile关键字的作用与正确使用场景
  • 了解volatile与synchronized的区别与联系
  • 能够使用volatile实现一个简单的缓存系统

1 内存可见性问题

1.1 什么是内存可见性

在多核CPU环境下,每个处理器都有自己的高速缓存。由于处理器的运行速度远大于内存访问速度,为了提高性能,处理器会将运算需要的数据提前缓存在高速缓存中。当程序在运行过程中,会将运算所需要的数据从主内存复制到CPU的高速缓存中,而高速缓存中的数据会在某个时间点刷新到主内存。

内存可见性问题指的是:当多个线程操作共享数据时,彼此无法看到对方线程对共享变量所做的修改。

轻松掌握Java多线程 - 第六章:volatile关键字

让我们通过一个简单的例子来理解这个问题:

package org.devlive.tutorial.multithreading.chapter06;

public class VisibilityProblemDemo {
    // 共享变量
    private static boolean flag = false;
    
    public static void main(String[] args) throws InterruptedException {
        // 创建线程1,当检测到flag为true时退出循环
        Thread thread1 = new Thread(() -> {
            System.out.println("线程1启动");
            // 当flag为false时,无限循环
            while (!flag) {
                // 空循环
            }
            System.out.println("线程1检测到flag变为true,退出循环");
        });
        
        // 创建线程2,将flag设置为true
        Thread thread2 = new Thread(() -> {
            try {
                // 休眠1秒,确保线程1先启动
                Thread.sleep(1000);
                System.out.println("线程2将flag设置为true");
                flag = true;
                System.out.println("线程2设置完成");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        
        // 启动线程
        thread1.start();
        thread2.start();
        
        // 等待线程执行完毕
        thread1.join();
        thread2.join();
    }
}

在这个例子中,你可能会觉得线程2将flag设置为true后,线程1应该能够检测到并退出循环。但在某些情况下(特别是在优化编译和多核CPU的环境下),线程1可能永远无法退出循环。这就是内存可见性问题的体现。

提示:如果你运行这个程序,可能在某些机器上线程1确实能够退出循环,但在其他机器上可能会一直循环下去。这取决于JVM的实现、CPU架构以及编译器的优化策略。

1.2 内存可见性问题的原因

内存可见性问题主要由以下几个原因导致:

  1. CPU缓存:每个CPU都有自己的缓存,线程1对变量的修改可能只更新了线程1所在CPU的缓存,而没有及时刷新到主内存。
  2. 编译器优化:为了提高性能,编译器和CPU会对指令进行重排序,可能导致指令的实际执行顺序与代码编写顺序不一致。
  3. JVM内存模型:Java内存模型允许JVM对代码进行各种优化,其中包括将变量存储在寄存器而不是内存中,这会导致其他线程无法及时看到变量的变化。

下面是一个图示,展示了内存可见性问题是如何发生的:

线程1(CPU核心1)                    线程2(CPU核心2)
+----------------+                +----------------+
| 本地缓存:       |                | 本地缓存:       |
| flag = false   |                | flag = true    |
+----------------+                +----------------+
        ↑                                 ↓
        |                                 |
        |                                 |
+--------------------------------------------------+
|                   主内存: flag = ?                 |
+--------------------------------------------------+

如图所示,线程2已经在自己的本地缓存中将flag设置为true,但尚未将这个更新刷新到主内存中。同时,线程1仍然使用本地缓存中的旧值(false)。

2 volatile的作用与使用场景

2.1 volatile关键字介绍

volatile是Java提供的一种轻量级的同步机制,它能够保证变量在多线程之间的可见性,但不能保证原子性。

volatile关键字主要有两个作用:

  1. 保证可见性:当一个线程修改了被volatile修饰的变量后,无论这个变量是否被缓存,其他线程都能立即看到最新值。
  2. 禁止指令重排序:volatile关键字会在指令序列中插入内存屏障,禁止特定类型的指令重排序,从而避免由于指令重排序导致的并发问题。

让我们修改前面的例子,使用volatile关键字来解决内存可见性问题:

package org.devlive.tutorial.multithreading.chapter06;

public class VolatileVisibilityDemo {
    // 使用volatile修饰共享变量
    private static volatile boolean flag = false;
    
    public static void main(String[] args) throws InterruptedException {
        // 创建线程1,当检测到flag为true时退出循环
        Thread thread1 = new Thread(() -> {
            System.out.println("线程1启动");
            // 当flag为false时,无限循环
            while (!flag) {
                // 空循环
            }
            System.out.println("线程1检测到flag变为true,退出循环");
        });
        
        // 创建线程2,将flag设置为true
        Thread thread2 = new Thread(() -> {
            try {
                // 休眠1秒,确保线程1先启动
                Thread.sleep(1000);
                System.out.println("线程2将flag设置为true");
                flag = true;
                System.out.println("线程2设置完成");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        
        // 启动线程
        thread1.start();
        thread2.start();
        
        // 等待线程执行完毕
        thread1.join();
        thread2.join();
    }
}

在这个修改后的版本中,我们使用volatile关键字修饰flag变量。这样,当线程2修改flag的值时,线程1能够立即看到这个变更,从而退出循环。

2.2 volatile的内存语义

为了更深入地理解volatile的工作原理,我们需要了解Java内存模型(JMM)以及volatile的内存语义。

在Java内存模型中,volatile变量的写操作和读操作分别具有以下内存语义:

  • volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

这种特性保证了,线程A对volatile变量的写入对线程B的读取可见,即线程A写入的值,能够被线程B读取到。

下面是一个图示,展示了volatile变量如何保证内存可见性:

线程1(CPU核心1)                    线程2(CPU核心2)
+----------------+                +----------------+
| 本地缓存:       |                | 本地缓存:       |
| flag = false   |<---+           | flag = true    |
+----------------+    |           +----------------+
        ↑             |                   ↓
        |             |                   |
        |             |                   |
+--------------------------------------------------+
|                主内存: flag = true               |
+--------------------------------------------------+
                      |
                      |
                      +--- volatile保证其他线程能看到最新值

2.3 volatile的适用场景

volatile关键字适用于以下场景:

  1. 状态标记:当一个变量作为状态标记时(如开关控制),通常使用volatile修饰。
  2. 双重检查锁定(Double-Checked Locking):在单例模式的双重检查锁定中,使用volatile可以防止由于指令重排序导致的问题。
  3. 独立观察:一个线程写入变量,另一个线程读取变量,两个线程之间没有其他共享变量,这种情况下可以使用volatile。

让我们看一个使用volatile实现的双重检查锁定单例模式:

package org.devlive.tutorial.multithreading.chapter06;

public class SafeSingleton {
    // 使用volatile修饰instance
    private static volatile SafeSingleton instance;
    
    // 私有构造函数
    private SafeSingleton() {
        System.out.println("创建SafeSingleton实例");
    }
    
    // 获取实例的方法
    public static SafeSingleton getInstance() {
        // 第一次检查
        if (instance == null) {
            // 同步代码块
            synchronized (SafeSingleton.class) {
                // 第二次检查
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
    
    public static void main(String[] args) {
        // 创建多个线程同时获取实例
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SafeSingleton singleton = SafeSingleton.getInstance();
                System.out.println(Thread.currentThread().getName() + " 获取到实例: " + singleton);
            }).start();
        }
    }
}

在这个例子中,我们使用volatile修饰instance变量。这样做的目的是防止指令重排序导致的问题。因为instance = new SafeSingleton()这一行代码实际上包含三个步骤:

  1. 分配对象的内存空间
  2. 初始化对象
  3. 将引用指向分配的内存空间

如果不使用volatile,这三个步骤可能会被重排序,可能导致其他线程在对象还没有完全初始化时就获取到了实例,从而导致错误。

注意:虽然双重检查锁定模式是volatile的一个经典应用场景,但在实际开发中,更推荐使用静态内部类或枚举实现单例模式,因为它们更简单且线程安全。

2.4 volatile不能解决的问题

虽然volatile能够保证可见性和禁止指令重排序,但它不能保证操作的原子性。这意味着,当一个操作需要先读取值,然后修改值,最后写回值的时候,volatile不能保证这个过程是原子的。

例如,多线程环境下的计数器:

package org.devlive.tutorial.multithreading.chapter06;

public class VolatileCounterDemo {
    // 使用volatile修饰计数器
    private static volatile int counter = 0;
    
    public static void main(String[] args) throws InterruptedException {
        // 创建10个线程,每个线程将counter递增1000次
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter++; // 非原子操作
                }
            });
            threads[i].start();
        }
        
        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        
        // 输出结果
        System.out.println("Expected: " + (10 * 1000));
        System.out.println("Actual: " + counter);
    }
}

在这个例子中,我们使用volatile修饰counter变量,但最终的结果很可能小于10000。这是因为counter++操作不是原子的,它包含三个步骤:读取counter的值、将值加1、将新值写回counter。在多线程环境下,这三个步骤可能会被其他线程的操作打断,导致最终结果不正确。

对于这种需要保证原子性的场景,应该使用synchronized或
java.util.concurrent.atomic包中的原子类,如AtomicInteger。

3 volatile与synchronized的区别

volatile和synchronized是Java中常用的两种同步机制,它们有着不同的特性和适用场景。

3.1 可见性与原子性

  • volatile:保证可见性和禁止指令重排序,但不保证原子性。
  • synchronized:保证可见性、原子性和有序性。

3.2 使用范围

  • volatile:只能修饰变量。
  • synchronized:可以修饰方法和代码块。

3.3 性能开销

  • volatile:轻量级,性能较好。
  • synchronized:重量级,会导致线程上下文切换,性能较差(虽然从Java 6开始,synchronized已经进行了许多优化)。

3.4 适用场景对比

  1. 如果只需要保证可见性,使用volatile即可。
  2. 如果需要保证原子性,应该使用synchronized或java.util.concurrent.atomic包中的原子类。
  3. 如果一个变量被多个线程访问,但只有一个线程修改,可以使用volatile保证可见性。
  4. 如果多个线程都需要修改这个变量,需要使用synchronized或其他锁机制保证原子性。

让我们通过一个例子来对比这两种同步机制:

package org.devlive.tutorial.multithreading.chapter06;

import java.util.concurrent.atomic.AtomicInteger;

public class SynchronizationComparisonDemo {
    // 使用volatile修饰的计数器,不能保证原子性
    private static volatile int volatileCounter = 0;
    
    // 使用synchronized保护的计数器
    private static int synchronizedCounter = 0;
    
    // 使用AtomicInteger的计数器
    private static AtomicInteger atomicCounter = new AtomicInteger(0);
    
    // synchronized方法,确保原子性
    private static synchronized void incrementSynchronizedCounter() {
        synchronizedCounter++;
    }
    
    public static void main(String[] args) throws InterruptedException {
        // 创建10个线程,分别递增三种计数器
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    volatileCounter++; // 非原子操作
                    incrementSynchronizedCounter(); // 使用synchronized保证原子性
                    atomicCounter.incrementAndGet(); // 使用AtomicInteger保证原子性
                }
            });
            threads[i].start();
        }
        
        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        
        // 输出结果
        System.out.println("Expected: " + (10 * 1000));
        System.out.println("Volatile Counter: " + volatileCounter);
        System.out.println("Synchronized Counter: " + synchronizedCounter);
        System.out.println("Atomic Counter: " + atomicCounter.get());
    }
}

运行这个程序,你会发现volatileCounter的值很可能小于10000,而synchronizedCounteratomicCounter的值一定是10000。这说明volatile不能保证原子性,而synchronized和AtomicInteger可以保证原子性。

4 volatile的内部原理

为了更深入地理解volatile的工作原理,我们需要了解Java内存模型以及volatile在底层的实现。

4.1 内存屏障(Memory Barrier)

volatile的底层实现主要依赖于内存屏障(Memory Barrier)指令。内存屏障是一种CPU指令,用于控制特定条件下的重排序和内存可见性。

在Java中,volatile通过插入内存屏障来实现以下功能:

  1. 保证可见性:当写一个volatile变量时,会在写操作后插入一个写屏障(Store Memory Barrier);当读一个volatile变量时,会在读操作前插入一个读屏障(Load Memory Barrier)。
  2. 禁止指令重排序:通过内存屏障,确保volatile变量的读写操作不会被重排序。

4.2 happens-before关系

Java内存模型(JMM)定义了一种happens-before关系,用来表示一个操作对另一个操作可见。如果操作A happens-before操作B,那么操作A的结果对操作B可见。

volatile变量的读写建立了happens-before关系:

  • 对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。

这意味着,当线程A写入一个volatile变量,线程B随后读取这个变量,那么线程A在写入volatile变量之前的所有操作对线程B都是可见的。

4.3 volatile的底层实现

在不同的硬件架构和JVM实现中,volatile的底层实现可能有所不同。但基本原理是一致的:

  1. x86架构:在x86架构上,写操作自动具有释放(release)语义,读操作自动具有获取(acquire)语义,所以volatile的写操作只需要在写后插入一个写屏障,而volatile的读操作不需要插入读屏障。
  2. 其他架构:在其他架构(如ARM)上,可能需要在volatile写后插入写屏障,在volatile读前插入读屏障。

以下是一个简化的示意图,展示了volatile在底层的实现原理:

// 写volatile变量
store value -> volatile variable
StoreStore barrier (防止写操作重排序)
StoreLoad barrier (确保其他处理器能看到该写操作)

// 读volatile变量
LoadLoad barrier (防止读操作重排序)
load value <- volatile variable
LoadStore barrier (防止读操作与后续写操作重排序)

注意:以上是一个简化的示意图,实际实现可能因JVM版本和硬件架构而异。

5 实战案例:使用volatile实现一个简单的缓存系统

下面我们将实现一个简单的缓存系统,使用volatile确保缓存一致性。

package org.devlive.tutorial.multithreading.chapter06;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class SimpleCache {
    // 使用volatile修饰缓存对象,确保可见性
    private static volatile Map<String, Object> cache = new HashMap<>();
    
    // 模拟从数据库加载数据
    private static Object loadFromDB(String key) {
        System.out.println("从数据库加载数据:" + key);
        try {
            // 模拟数据库操作的延迟
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "Data for " + key;
    }
    
    // 从缓存获取数据,如果缓存中没有则从数据库加载
    public static Object get(String key) {
        // 从缓存中获取数据
        Object value = cache.get(key);
        if (value == null) {
            synchronized (SimpleCache.class) {
                // 双重检查,防止多个线程同时加载同一个数据
                value = cache.get(key);
                if (value == null) {
                    // 从数据库加载数据
                    value = loadFromDB(key);
                    // 更新缓存
                    Map<String, Object> newCache = new HashMap<>(cache);
                    newCache.put(key, value);
                    cache = newCache; // 原子更新整个缓存,确保可见性
                }
            }
        }
        return value;
    }
    
    // 更新缓存
    public static void put(String key, Object value) {
        synchronized (SimpleCache.class) {
            Map<String, Object> newCache = new HashMap<>(cache);
            newCache.put(key, value);
            cache = newCache; // 原子更新整个缓存,确保可见性
        }
    }
    
    // 清除缓存
    public static void clear() {
        synchronized (SimpleCache.class) {
            cache = new HashMap<>();
        }
    }
    
    public static void main(String[] args) {
        // 创建多个线程同时访问缓存
        for (int i = 0; i < 10; i++) {
            final int index = i;
            new Thread(() -> {
                Object data = get("key" + (index % 5));
                System.out.println(Thread.currentThread().getName() + " 获取数据:" + data);
            }).start();
        }
    }
}

在这个实战案例中,我们使用volatile修饰缓存Map对象,并且在更新缓存时创建一个新的Map对象,而不是直接修改现有的Map。这种方式保证了缓存的一致性和可见性。

注意:虽然这个简单的缓存系统可以工作,但在实际应用中,我们通常会使用ConcurrentHashMap或专业的缓存库如Caffeine、Guava Cache等。

6 常见问题与解决方案

在使用volatile关键字时,有一些常见问题需要注意:

6.1 volatile不保证原子性

问题:volatile变量的复合操作(如i++)不是原子的,可能导致线程安全问题。

解决方案:

  1. 使用synchronized关键字保证原子性。
  2. 使用java.util.concurrent.atomic包中的原子类,如AtomicInteger。

示例:

package org.devlive.tutorial.multithreading.chapter06;

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicSolutionDemo {
    // 使用AtomicInteger替代volatile int
    private static AtomicInteger counter = new AtomicInteger(0);
    
    public static void main(String[] args) throws InterruptedException {
        // 创建10个线程,每个线程将counter递增1000次
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.incrementAndGet(); // 原子操作
                }
            });
            threads[i].start();
        }
        
        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        
        // 输出结果
        System.out.println("Expected: " + (10 * 1000));
        System.out.println("Actual: " + counter.get());
    }
}

6.2 过度使用volatile

问题:过度使用volatile可能导致不必要的内存同步,影响性能。

解决方案:

  1. 只在必要的场景下使用volatile。
  2. 对于复杂的同步需求,考虑使用java.util.concurrent包中的工具类。

6.3 volatile与单例模式

问题:在双重检查锁定单例模式中,如果不使用volatile修饰instance变量,可能会因为指令重排序导致问题。

解决方案:

  1. 使用volatile修饰instance变量。
  2. 考虑使用静态内部类或枚举实现单例模式,这些方式更简单且线程安全。

示例(静态内部类实现单例):

package org.devlive.tutorial.multithreading.chapter06;

public class StaticInnerClassSingleton {
    // 私有构造函数
    private StaticInnerClassSingleton() {
        System.out.println("创建StaticInnerClassSingleton实例");
    }
    
    // 静态内部类,持有单例实例
    private static class SingletonHolder {
        private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
    }
    
    // 获取实例的方法
    public static StaticInnerClassSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
    public static void main(String[] args) {
        // 创建多个线程同时获取实例
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                StaticInnerClassSingleton singleton = StaticInnerClassSingleton.getInstance();
                System.out.println(Thread.currentThread().getName() + " 获取到实例: " + singleton);
            }).start();
        }
    }
}

6.4 volatile数组

问题:当使用volatile修饰数组时,只有数组引用是volatile的,数组元素不是volatile的。

解决方案:

  1. 使用AtomicReferenceArray。
  2. 对数组元素的访问加锁。
  3. 考虑使用CopyOnWriteArrayList等线程安全的集合类。

示例:

package org.devlive.tutorial.multithreading.chapter06;

import java.util.concurrent.atomic.AtomicIntegerArray;

public class VolatileArrayDemo {
    // 使用volatile修饰数组,只有数组引用是volatile的,数组元素不是
    private static volatile int[] volatileArray = new int[10];
    
    // 使用AtomicIntegerArray保证元素的原子性
    private static AtomicIntegerArray atomicArray = new AtomicIntegerArray(10);
    
    public static void main(String[] args) throws InterruptedException {
        // 创建10个线程,每个线程操作不同索引的元素
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            final int index = i;
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    // 对volatileArray[index]进行非原子的自增操作
                    volatileArray[index]++;
                    
                    // 对atomicArray[index]进行原子的自增操作
                    atomicArray.incrementAndGet(index);
                }
            });
            threads[i].start();
        }
        
        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        
        // 输出结果
        boolean volatileArrayCorrect = true;
        boolean atomicArrayCorrect = true;
        
        for (int i = 0; i < 10; i++) {
            if (volatileArray[i] != 1000) {
                volatileArrayCorrect = false;
            }
            if (atomicArray.get(i) != 1000) {
                atomicArrayCorrect = false;
            }
        }
        
        System.out.println("Expected value for each element: 1000");
        System.out.println("Volatile Array correct: " + volatileArrayCorrect);
        System.out.println("Atomic Array correct: " + atomicArrayCorrect);
    }
}

6.5 volatile的性能考量

问题:虽然volatile比synchronized轻量级,但过度使用仍会影响性能。

解决方案:

  1. 在性能关键的代码中,谨慎使用volatile。
  2. 考虑使用Java 8引入的StampedLock或其他高性能同步工具。
  3. 进行性能测试,确定最适合的同步机制。

7 小结

在本章中,我们深入学习了Java中的volatile关键字,主要内容包括:

  1. 内存可见性问题:了解了多线程环境下的内存可见性问题及其产生原因。
  2. volatile的作用:掌握了volatile的两个主要作用:保证可见性和禁止指令重排序。
  3. volatile的适用场景:学习了volatile的适当使用场景,包括状态标记、双重检查锁定等。
  4. volatile的局限性:认识到volatile不能保证操作的原子性,对于需要原子性的操作,应该使用synchronized或原子类。
  5. volatile与synchronized的区别:比较了这两种同步机制的异同点,了解它们各自的适用场景。

实战案例:通过实例展示了如何使用volatile实现线程安全的开关控制和简单的缓存系统。

  1. 常见问题与解决方案:介绍了使用volatile时的常见问题及其解决方法。

volatile是Java并发编程中的一个重要工具,它提供了一种轻量级的同步机制。合理使用volatile可以在某些场景下避免使用重量级的synchronized关键字,从而提高程序性能。但需要注意,volatile并不适用于所有场景,特别是需要保证操作原子性的场景。

在下一章中,我们将学习ThreadLocal的使用,它是另一种重要的线程安全机制,用于实现线程隔离。

源代码地址:
https://github.com/qianmoQ/tutorial/tree/main/java-multithreading-tutorial/src/main/java/org/devlive/tutorial/multithreading/chapter06

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表