面试题:用过ThreadLocal吗?ThreadLocal是在哪个包下的?看过ThreadLocal源码吗?讲一下ThreadLocal的get和put是怎么实现的?

简介: 字节面试题:用过ThreadLocal吗?ThreadLocal是在哪个包下的?看过ThreadLocal源码吗?讲一下ThreadLocal的get和put是怎么实现的?

面试题:用过ThreadLocal吗?ThreadLocal是在哪个包下的?看过ThreadLocal源码吗?讲一下ThreadLocal的get和put是怎么实现的?


介绍ThreadLocal


ThreadLocal是Java中的一个线程封闭工具,允许线程在其范围内创建一个本地变量。每个线程都有自己的变量副本,这使得线程可以独立地访问自己的变量副本,而不会与其他线程的变量发生冲突。ThreadLocal通常用于保存线程私有的上下文信息,如数据库连接、会话信息等。


ThreadLocal所在的包


ThreadLocal类位于Java的java.lang包下。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 使用ThreadLocal模拟数据库连接池的测试方法
 */
public class ThreadLocalTest {
    // 模拟数据库连接对象
    static class Connection {
        // 数据库连接ID
        private final String connectionId;

        public Connection(String connectionId) {
            this.connectionId = connectionId;
        }

        public String getConnectionId() {
            return connectionId;
        }
    }

    // 模拟数据库连接池
    static class ConnectionPool {
        // 使用ThreadLocal存储数据库连接
        private static final ThreadLocal<Connection> threadLocalConnection = ThreadLocal.withInitial(() -> new Connection("Connection-1"));

        // 获取数据库连接
        public static Connection getConnection() {
            return threadLocalConnection.get();
        }

        // 归还数据库连接(这里简化为清除ThreadLocal中的连接)
        public static void releaseConnection() {
            threadLocalConnection.remove();
        }
    }

    // 模拟公共数据集
    static class SharedDataSet {
        // 使用ThreadLocal存储数据集
        private static final ThreadLocal<String> threadLocalDataSet = new ThreadLocal<>();

        // 设置数据集
        public static void setDataSet(String dataSet) {
            threadLocalDataSet.set(dataSet);
        }

        // 获取数据集
        public static String getDataSet() {
            return threadLocalDataSet.get();
        }

        // 清除数据集
        public static void clearDataSet() {
            threadLocalDataSet.remove();
        }
    }

    // 模拟数据库查询任务
    static class DatabaseQueryTask implements Runnable {
        @Override
        public void run() {
            // 从连接池获取数据库连接
            Connection connection = ConnectionPool.getConnection();
            System.out.println("线程 " + Thread.currentThread().getName() + " 使用连接进行查询: " + connection.getConnectionId());

            // 模拟数据库查询
            try {
                System.out.println("线程 " + Thread.currentThread().getName() + " 开始模拟数据库查询...");
                Thread.sleep(1000);
                System.out.println("线程 " + Thread.currentThread().getName() + " 完成模拟数据库查询!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 获取数据集并输出
            String dataSet = SharedDataSet.getDataSet();
            System.out.println("线程 " + Thread.currentThread().getName() + " 获取数据集: " + dataSet);

            // 设置数据集并输出
            SharedDataSet.setDataSet("Data from " + Thread.currentThread().getName());
            System.out.println("线程 " + Thread.currentThread().getName() + " 设置数据集: Data from " + Thread.currentThread().getName());

            // 归还数据库连接
            ConnectionPool.releaseConnection();
            System.out.println("线程 " + Thread.currentThread().getName() + " 归还连接: " + connection.getConnectionId());
        }
    }

    public static void main(String[] args) {
        // 创建一个线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        // 提交多个数据库查询任务
        for (int i = 0; i < 10; i++) {
            executorService.submit(new DatabaseQueryTask());
        }

        // 关闭线程池
        executorService.shutdown();
    }
}

  • 运行结果
线程 pool-1-thread-2 使用连接进行查询: Connection-1
线程 pool-1-thread-2 开始模拟数据库查询...
线程 pool-1-thread-4 使用连接进行查询: Connection-1
线程 pool-1-thread-4 开始模拟数据库查询...
线程 pool-1-thread-1 使用连接进行查询: Connection-1
线程 pool-1-thread-1 开始模拟数据库查询...
线程 pool-1-thread-5 使用连接进行查询: Connection-1
线程 pool-1-thread-5 开始模拟数据库查询...
线程 pool-1-thread-3 使用连接进行查询: Connection-1
线程 pool-1-thread-3 开始模拟数据库查询...
线程 pool-1-thread-2 完成模拟数据库查询!
线程 pool-1-thread-2 获取数据集: null
线程 pool-1-thread-2 设置数据集: Data from pool-1-thread-2
线程 pool-1-thread-4 完成模拟数据库查询!
线程 pool-1-thread-4 获取数据集: null
线程 pool-1-thread-4 设置数据集: Data from pool-1-thread-4
线程 pool-1-thread-1 完成模拟数据库查询!
线程 pool-1-thread-1 获取数据集: null
线程 pool-1-thread-1 设置数据集: Data from pool-1-thread-1
线程 pool-1-thread-5 完成模拟数据库查询!
线程 pool-1-thread-5 获取数据集: null
线程 pool-1-thread-5 设置数据集: Data from pool-1-thread-5
线程 pool-1-thread-3 完成模拟数据库查询!
线程 pool-1-thread-3 获取数据集: null
线程 pool-1-thread-3 设置数据集: Data from pool-1-thread-3
线程 pool-1-thread-2 归还连接: Connection-1
线程 pool-1-thread-4 归还连接: Connection-1
线程 pool-1-thread-1 归还连接: Connection-1
线程 pool-1-thread-5 归还连接: Connection-1
线程 pool-1-thread-3 归还连接: Connection-1


以下是运行结果的一部分:

线程 pool-1-thread-2 使用连接进行查询: Connection-1
线程 pool-1-thread-2 开始模拟数据库查询...
线程 pool-1-thread-4 使用连接进行查询: Connection-1
线程 pool-1-thread-4 开始模拟数据库查询...
线程 pool-1-thread-1 使用连接进行查询: Connection-1
线程 pool-1-thread-1 开始模拟数据库查询...
线程 pool-1-thread-5 使用连接进行查询: Connection-1
线程 pool-1-thread-5 开始模拟数据库查询...
线程 pool-1-thread-3 使用连接进行查询: Connection-1
线程 pool-1-thread-3 开始模拟数据库查询...
线程 pool-1-thread-2 完成模拟数据库查询!
线程 pool-1-thread-2 获取数据集: null
线程 pool-1-thread-2 设置数据集: Data from pool-1-thread-2
线程 pool-1-thread-4 完成模拟数据库查询!
线程 pool-1-thread-4 获取数据集: null
线程 pool-1-thread-4 设置数据集: Data from pool-1-thread-4
线程 pool-1-thread-1 完成模拟数据库查询!
线程 pool-1-thread-1 获取数据集: null
线程 pool-1-thread-1 设置数据集: Data from pool-1-thread-1
线程 pool-1-thread-5 完成模拟数据库查询!
线程 pool-1-thread-5 获取数据集: null
线程 pool-1-thread-5 设置数据集: Data from pool-1-thread-5
线程 pool-1-thread-3 完成模拟数据库查询!
线程 pool-1-thread-3 获取数据集: null
线程 pool-1-thread-3 设置数据集: Data from pool-1-thread-3
线程 pool-1-thread-2 归还连接: Connection-1
线程 pool-1-thread-4 归还连接: Connection-1
线程 pool-1-thread-1 归还连接: Connection-1
线程 pool-1-thread-5 归还连接: Connection-1
线程 pool-1-thread-3 归还连接: Connection-1


运行结果解释:


  1. 每个线程从连接池获取连接后,开始模拟数据库查询。查询完成后,归还连接。
  2. 在查询过程中,每个线程都尝试获取、设置和输出公共数据集。每次获取的数据集都是 null,因为每个线程都是第一次访问数据集,所以获取到的值为 null。
  3. 在设置数据集时,每个线程都会设置自己的数据集,例如线程 pool-1-thread-2 设置了数据集为 Data from pool-1-thread-2。
  4. 这些操作表明,每个线程对数据集的操作互不影响,每个线程都有自己的数据集副本,确保了线程间数据的隔离和安全性。


查看ThreadLocal源码

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadLocal<T> {
    // 使用 AtomicInteger 生成哈希码,确保线程安全
    private static final AtomicInteger nextHashCode = new AtomicInteger();
    // 线程局部变量的唯一标识,确保每个 ThreadLocal 实例的唯一性
    private final int threadLocalHashCode = nextHashCode();
    
    // 生成下一个哈希码
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(0x61c88647);
    }

    // 初始化方法,为每个线程的局部变量提供初始值
    protected T initialValue() {
        return null;
    }

    // 获取当前线程的局部变量值
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

    // 设置当前线程的局部变量值
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }

    // 创建线程的局部变量值映射
    private void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

    // 获取当前线程的局部变量值映射
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

    // 为当前线程设置初始值
    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
        return value;
    }

    // ThreadLocalMap 类
    static class ThreadLocalMap {
        static class Entry {
            final ThreadLocal<?> key;
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                key = k;
                value = v;
            }
        }

        private Entry[] table;

        // 构造函数
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[16];
            int index = firstKey.threadLocalHashCode & (table.length - 1);
            table[index] = new Entry(firstKey, firstValue);
        }

        // 根据 ThreadLocal 获取 Entry
        Entry getEntry(ThreadLocal<?> key) {
            int index = key.threadLocalHashCode & (table.length - 1);
            return table[index];
        }

        // 设置 Entry
        void set(ThreadLocal<?> key, Object value) {
            int index = key.threadLocalHashCode & (table.length - 1);
            table[index] = new Entry(key, value);
        }
    }
}


上面的代码是对 ThreadLocal 类的简化版本的实现,它展示了 ThreadLocal 类的核心部分。下面是对代码的解释:


  • ThreadLocal 类中的 nextHashCode 和 threadLocalHashCode 用于生成哈希码,以及为每个 ThreadLocal 实例提供唯一的标识。
  • get() 方法用于获取当前线程的局部变量值,首先获取当前线程的 ThreadLocalMap,然后通过 ThreadLocal 实例在 ThreadLocalMap 中获取对应的值。
  • set() 方法用于设置当前线程的局部变量值,如果当前线程的 ThreadLocalMap 存在,则直接设置值,否则创建新的 ThreadLocalMap。
  • ThreadLocalMap 类用于存储线程的局部变量值,它内部维护了一个 Entry 数组,通过哈希算法进行快速访问。
  • Entry 类表示 ThreadLocal 实例与局部变量值之间的映射关系。


下面是一个简化版本的 ThreadLocalMap 的代码示例,它展示了 ThreadLocalMap 的基本原理和功能:

public class ThreadLocalMap {

    // Entry 类表示 ThreadLocal 实例与局部变量值之间的映射关系
    static class Entry {
        final ThreadLocal<?> key; // ThreadLocal 实例
        Object value; // 局部变量值

        Entry(ThreadLocal<?> k, Object v) {
            key = k;
            value = v;
        }
    }

    private Entry[] table; // Entry 数组

    // 构造函数,初始化 Entry 数组
    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        table = new Entry[16];
        int index = firstKey.threadLocalHashCode & (table.length - 1);
        table[index] = new Entry(firstKey, firstValue);
    }

    // 根据 ThreadLocal 获取对应的 Entry
    Entry getEntry(ThreadLocal<?> key) {
        int index = key.threadLocalHashCode & (table.length - 1);
        return table[index];
    }

    // 设置 ThreadLocal 对应的局部变量值
    void set(ThreadLocal<?> key, Object value) {
        int index = key.threadLocalHashCode & (table.length - 1);
        table[index] = new Entry(key, value);
    }
}

下面是对代码的解释:


  • ThreadLocalMap 内部包含一个静态内部类 Entry,用于表示 ThreadLocal 实例与局部变量值之间的映射关系。
  • ThreadLocalMap 内部维护了一个 Entry 数组 table,用于存储 ThreadLocal 实例与局部变量值之间的映射关系。
  • 在 ThreadLocalMap 的构造函数中,通过给定的 ThreadLocal 实例和局部变量值,初始化 table 数组。
  • getEntry(ThreadLocal key) 方法根据给定的 ThreadLocal 实例获取对应的 Entry。
  • set(ThreadLocal key, Object value) 方法用于设置 ThreadLocal 实例对应的局部变量值。
  • ThreadLocalMap 的实现采用了简单的哈希表结构,它通过 ThreadLocal 实例的哈希码来定位存储位置,从而实现了快速访问。每个 Entry 对象包含了 ThreadLocal 实例和对应的局部变量值,通过数组索引来访问和设置。


ThreadLocal的get和put实现


ThreadLocal的get和put方法实现了线程本地变量的存取,它们是通过每个线程内部维护的一个Map来实现的。


get方法实现

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

put方法实现

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}


案例与代码示例


线程上下文管理器案例


线程上下文管理器案例是一个示例,用于展示如何利用 Java 中的 ThreadLocal 实现线程间的数据隔离。在多线程编程中,有时候我们需要在不同的线程中共享数据,但又希望这些数据在每个线程中是独立的,互不影响的。线程上下文管理器通过 ThreadLocal 实现了这种需求。


具体来说,线程上下文管理器案例中的 ThreadContextManager 类提供了 setUserName 和 getUserName 方法,用于在当前线程中设置和获取用户名。每个线程都有自己的 ThreadLocal 实例,因此对于每个线程来说,调用 setUserName 方法设置的用户名都是独立的,互不影响的。


这种机制对于需要在线程间传递上下文信息、保存用户会话信息等场景非常有用。例如,在 Web 开发中,可以利用线程上下文管理器在不同的线程中传递用户的身份信息,而不必通过参数的方式传递,从而简化了代码的编写和维护。

// ThreadContextManager.java
public class ThreadContextManager {
    // 使用ThreadLocal存储用户名
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();

    // 设置当前线程的用户名
    public static void setUserName(String userName) {
        threadLocal.set(userName);
    }

    // 获取当前线程的用户名
    public static String getUserName() {
        return threadLocal.get();
    }

    public static void main(String[] args) {
        // 创建两个线程,并分别设置不同的用户名
        Thread thread1 = new Thread(() -> {
            ThreadContextManager.setUserName("User1");
            System.out.println("Thread1: User name is " + ThreadContextManager.getUserName());
        });

        Thread thread2 = new Thread(() -> {
            ThreadContextManager.setUserName("User2");
            System.out.println("Thread2: User name is " + ThreadContextManager.getUserName());
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程执行结束
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 在主线程中获取用户名
        System.out.println("Main thread: User name is " + ThreadContextManager.getUserName());

        // 在主线程中设置新的用户名
        ThreadContextManager.setUserName("NewUser");

        // 再次获取用户名,确保设置成功
        System.out.println("Main thread: User name is " + ThreadContextManager.getUserName());
    }
}

  • 这个运行结果可以说明什么吗?
Thread1: User name is User1
Thread2: User name is User2
Main thread: User name is null
Main thread: User name is NewUser


这个运行结果说明了以下几点:


  1. 在两个线程中,分别设置了不同的用户名:
  • 线程1设置的用户名为 “User1”;
  • 线程2设置的用户名为 “User2”。


  1. 在主线程中,因为没有设置过用户名,所以调用 getUserName 方法返回的是 null。


  1. 在主线程中,首先设置了新的用户名为 “NewUser”,然后再次调用 getUserName 方法,返回的是 “NewUser”。


这个运行结果说明了每个线程都有自己独立的用户名副本,互不影响。在每个线程中,通过 ThreadContextManager 设置的用户名只在当前线程内可见,不会被其他线程看到,从而确保了线程间数据的隔离性和安全性。


数据库连接池案例


在数据库连接池部分使用 ThreadLocal,其主要作用是为每个线程提供独立的数据库连接,从而保证线程间的数据隔离和线程安全性。


具体来说,数据库连接池的设计通常是为了复用数据库连接,避免频繁地创建和销毁连接,提高系统性能。而使用 ThreadLocal 可以保证每个线程都拥有自己的数据库连接副本,不同线程之间相互独立,互不影响。


在具体实现中,数据库连接池管理类会在每个线程中维护一个数据库连接的副本。当线程需要访问数据库时,通过 ThreadLocal 获取线程对应的数据库连接副本,进行数据库操作;操作完成后,再将连接释放回连接池,线程本地的数据库连接对象也随之被清理,确保了资源的安全释放和线程间的数据隔离。


如果不使用 ThreadLocal,而是直接在数据库连接池中管理共享的连接对象,可能会导致以下问题:


  1. 线程安全性问题: 如果多个线程共享相同的数据库连接对象,那么在多线程环境下可能会出现竞态条件和线程安全性问题。多个线程同时对同一个连接进行操作可能导致连接状态不一致或数据错乱。
  2. 连接泄漏问题: 如果不使用 ThreadLocal 来管理连接,而是将连接对象作为共享资源,那么在多线程环境下很容易出现连接泄漏问题。一个线程使用完连接后没有及时释放,其他线程无法获取到连接,导致连接池资源耗尽。
  3. 连接复用困难: 如果不使用连接池中的连接对象,而是每个线程都创建自己的连接,那么连接的复用就会变得困难。频繁地创建和销毁连接会增加系统开销,并且可能导致数据库性能下降。
  4. 性能下降: 每次请求都需要创建新的连接或者在连接池中竞争连接资源,会增加系统开销和响应时间,导致性能下降。
public class DatabaseConnectionManager {
    private static final ThreadLocal<Connection> connectionHolder = 
                            ThreadLocal.withInitial(() -> createConnection());

    private static Connection createConnection() {
        // 实现创建数据库连接的逻辑
    }

    public static Connection getConnection() {
        return connectionHolder.get();
    }
}

ThreadLocal的应用场景


ThreadLocal通常用于需要在线程之间传递数据,但不希望通过方法参数传递的场景,如Web应用中的会话管理、数据库连接管理等。


Web应用中的会话管理


在一个基于Web的应用程序中,会话管理是非常重要的,因为它允许我们在用户发出多个HTTP请求时跟踪用户的状态和数据。在传统的基于Servlet的Web应用中,通常会使用 HttpSession 来管理用户的会话信息。然而,如果我们在处理请求的过程中没有适当地管理会话信息,会带来一些潜在的问题:


  1. 线程安全性问题: 在多线程环境下,如果多个线程共享同一个 HttpSession 实例,可能会导致线程安全性问题。例如,在一个请求修改了 HttpSession 中的数据,而同时另一个请求也在修改相同的数据,就会导致数据不一致或者丢失的情况。
  2. 数据泄露问题: 如果在处理请求的过程中没有适当地清理会话信息,可能会导致数据泄露的问题。例如,如果一个用户的会话信息被错误地共享给了另一个用户,那么可能会泄露敏感信息。
  3. 并发访问问题: 如果没有合适的机制来处理多个线程同时访问同一个 HttpSession 实例的情况,可能会导致并发访问问题,例如临界资源竞争或者死锁。


为了解决上述问题,可以使用 ThreadLocal 来管理每个线程的会话信息。ThreadLocal 可以确保每个线程都拥有自己的会话信息副本,互不干扰,从而避免了多线程并发访问的问题。

/**
 * 在一个基于Web的应用程序中,每个HTTP请求都是一个独立的线程。
 * 为了在请求处理过程中共享会话信息,可以使用ThreadLocal来存储当前用户的会话信息。
 */
public class SessionManager {
    // 使用ThreadLocal存储当前线程的用户会话信息
    private static final ThreadLocal<UserSession> userSessionThreadLocal = new ThreadLocal<>();

    /**
     * 设置当前线程的用户会话信息。
     *
     * @param userSession 当前线程的用户会话信息
     */
    public static void setUserSession(UserSession userSession) {
        userSessionThreadLocal.set(userSession);
    }

    /**
     * 获取当前线程的用户会话信息。
     *
     * @return 当前线程的用户会话信息
     */
    public static UserSession getUserSession() {
        return userSessionThreadLocal.get();
    }
}

如果没有 ThreadLocal,而直接在多线程环境中共享 HttpSession 实例,则可能会导致上述提到的问题。因为每个线程都可以同时访问和修改同一个 HttpSession 实例,可能会造成数据的不一致性、泄露和并发访问问题。因此,使用 ThreadLocal 可以有效地解决这些问题,确保每个线程都能够独立地管理自己的会话信息,从而提高了系统的健壮性和安全性。


相关文章
|
18天前
|
SQL 关系型数据库 MySQL
字节面试:MySQL自增ID用完会怎样?
字节面试:MySQL自增ID用完会怎样?
26 0
字节面试:MySQL自增ID用完会怎样?
|
28天前
|
存储 Java 容器
研二学妹面试字节,竟倒在了ThreadLocal上,这是不要应届生还是不要女生啊?
【6月更文挑战第1天】研二学妹面试字节,竟倒在了ThreadLocal上,这是不要应届生还是不要女生啊?
29 5
|
5天前
|
存储 安全 Java
《ArrayList & HashMap 源码类基础面试题》面试官们最喜欢问的ArrayList & HashMap源码类初级问,你都会了?
《ArrayList & HashMap 源码类基础面试题》面试官们最喜欢问的ArrayList & HashMap源码类初级问,你都会了?
9 0
|
12天前
|
存储 Java 数据库连接
来探讨一下最近面试问的ThreadLocal问题
来探讨一下最近面试问的ThreadLocal问题
|
20天前
|
安全 Java 数据安全/隐私保护
Java基础4-一文搞懂String常见面试题,从基础到实战,更有原理分析和源码解析!(二)
Java基础4-一文搞懂String常见面试题,从基础到实战,更有原理分析和源码解析!(二)
21 0
|
20天前
|
JSON 安全 Java
Java基础4-一文搞懂String常见面试题,从基础到实战,更有原理分析和源码解析!(一)
Java基础4-一文搞懂String常见面试题,从基础到实战,更有原理分析和源码解析!(一)
30 0
|
2月前
|
前端开发 JavaScript 程序员
async-validator 源码学习(一):文档翻译,2024年最新如何面试大厂
async-validator 源码学习(一):文档翻译,2024年最新如何面试大厂
|
2月前
|
JavaScript 中间件 前端开发
[评论送书 ]手撕源码,实现一个Koa。,2024年最新学生会面试答题技巧
[评论送书 ]手撕源码,实现一个Koa。,2024年最新学生会面试答题技巧
|
2月前
|
数据安全/隐私保护 Python 算法
Python 蜻蜓fm有声书批量下载 支持账号登录 原创源码,2024年最新Python面试回忆录
Python 蜻蜓fm有声书批量下载 支持账号登录 原创源码,2024年最新Python面试回忆录
|
2月前
|
消息中间件 关系型数据库 MySQL
MySQL 到 Kafka 实时数据同步实操分享(1),字节面试官职级
MySQL 到 Kafka 实时数据同步实操分享(1),字节面试官职级