【源码】【Java并发】【ThreadLocal】适合中学者体质的ThreadLocal源码阅读

简介: 前言 下面,跟上主播的节奏,马上开始ThreadLocal源码的阅读( ̄▽ ̄)" 内部结构 如下图所示,我们可以知道,每个线程,都有自己的threadLocals字段,指向ThreadLocalMap

👋hi,我不是一名外包公司的员工,也不会偷吃茶水间的零食,我的梦想是能写高端CRUD

🔥 2025本人正在沉淀中... 博客更新速度++

👍 欢迎点赞、收藏、关注,跟上我的更新节奏

📚欢迎订阅专栏,专栏名《在2B工作中寻求并发是否搞错了什么》

前言

经过了上一篇的学习,聪明的你一定知道了ThradLocal的怎么样使用的。

【Java并发】【ThreadLocal】适合初学体质的ThreadLocal

下面,跟上主播的节奏,马上开始ThreadLocal源码的阅读( ̄▽ ̄)"

内部结构

如下图所示,我们可以知道,每个线程,都有自己的threadLocals字段,指向ThreadLocalMap

ThreadLocalMap中有一个Entry数组(table),用来存储我们set进ThreadLocal的值。

Entry的key指向ThreadLocal(弱引用),value就是我们set的值(强引用)。
image.png

Set流程

image.png

// set方法入口
public void set(T value) {
   
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
   
        // 设置值
        map.set(this, value);
    } else {
   
        // 为当前线程创建ThradLocalMap
        createMap(t, value);
    }
}


// getMap方法。获取当前线程,ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
   
    return t.threadLocals;
}

map为null的情况,开始初始化

初始化

void createMap(Thread t, T firstValue) {
   
    // 为当前线程设置ThreadLocalMap
    // key是TheadLocal,value是我们要塞入ThreadLocal线程的值
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

具体new的逻辑

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
   
    // 设置table大小,初始容量为16。ThreadLocalMap的table就是用来存Entry的。
    table = new Entry[INITIAL_CAPACITY];    
    // 哈希算法算法,决定新的Entry插入到哪个槽里,后面会具体说这个。
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    // 当前在ThreadLocalMap中的Entry数量
    size = 1;
    // 设置扩容的阈值,2/3的时候扩容
    setThreshold(INITIAL_CAPACITY);
}

初始容量,16个,强制要求为2的幂次,用于优化位运算性能。通过静态final修饰确保全局唯一且不可修改。

/**
 * The initial capacity -- MUST be a power of two.
 */
private static final int INITIAL_CAPACITY = 16;

扩容阈值,2/3 的时候扩容

/**
 * Set the resize threshold to maintain at worst a 2/3 load factor.
 */
private void setThreshold(int len) {
   
    threshold = len * 2 / 3;
}

好了,让我们具体来说说说是怎么哈希的

int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);

为什么是 &? firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)?

当容量 INITIAL_CAPACITY 是 2 的幂(如 16)时,INITIAL_CAPACITY - 1 的二进制形式为全 1(例如 15 -> 1111)。此时,hash & (INITIAL_CAPACITY - 1) 等效于 hash % INITIAL_CAPACITY,但位运算的效率远高于取模运算。

firstKey.threadLocalHashCode,这个咋来的?

private final int threadLocalHashCode = nextHashCode();

// nextHashCode方法
private static int nextHashCode() {
   
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

// nextHashCode,从0开始的hashCode
private static AtomicInteger nextHashCode = new AtomicInteger();

// HASH_INCREMENT
private static final int HASH_INCREMENT = 0x61c88647;

为什么这个魔数是0x61c88647?

0x61c88647 近似于 (√5 - 1)/2 * 2^32(黄金分割比例的 32 位扩展)。这种设计确保哈希码在 2 的幂容量下均匀分布,减少冲突概率。

map不为null的情况,直接set

private void set(ThreadLocal<?> key, Object value) {
   
    // ThreadLocalMap的table,table是个数组里面是放Entry的
    Entry[] tab = table;
    int len = tab.length
    // hash到哪个槽位(上面初始化的时候,具体有说)
    int i = key.threadLocalHashCode & (len-1);

    // 判断当前的槽是否已经有Entry了
    // - 如果有:就for循环table,看是不是这次要更新的槽。
    //      - 是的话更新。
    //         - 不是的话,说明存在哈希冲突,需要通过开放地址法,找到为空的槽插入。
    // - 如果没有:就不执行下面的for循环,直接插入即可。
    // ===== ThreadLocal哈希冲突解决 =====
    // 我们都知道,这里的哈希冲突解决方案是 ---》 开放地址法(线性探测法)
    // 这里的处理:如果enty不为null的话,就一直i+1往下找下一个,直到table[i] == null。
    //(下面有nextIndex的代码,就是i+1。)
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
   
        ThreadLocal<?> k = e.get();

        // 如果当前Entry的key,就是这次set需要的Entry的key,这里就更新
        if (k == key) {
   
            e.value = value;
            return;
        }

        // 如果这个Entry的key为null(可能是线程执行完被GC了)
        if (k == null) {
   
            // 清除这个Entry(后面会具体说怎么清除的)
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    // 对该槽赋值
    tab[i] = new Entry(key, value);
    // table中Entry数量+1
    int sz = ++size;
    // 1、cleanSomeSlots清理没用的槽
    // 2、如果清空失败 并且 table中的Entry数量大于等于扩容阈值
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();    // 重新hash
}

寻找下一个地址的方法nextIndex。

private static int nextIndex(int i, int len) {
   
    return ((i + 1 < len) ? i + 1 : 0);
}

rehash做了什么?

private void rehash() {
   
    // 清除过期的Entry(Entry的key为null就是过期的意思,后面会说具体说怎么清理的)
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    // 通过主动提前扩容,防止哈希表在临界负载时因性能骤降影响用户体验
    // 当前Entry的数量 大于等于 扩容阈值的1/2
    if (size >= threshold - threshold / 4)
        resize();
}

为什么是threshold - threshold / 4

因为要保持低附载。所以我们也可以说这是1 / 2扩容。

在开放寻址法中,通过主动降低扩容阈值(从 2/3 容量降至 3/4 * 2/3 = 1/2 容量),确保哈希表始终处于低负载状态,从而:减少线性探测的冲突概率,平滑性能波动,避免滞后现象,以可控的内存增长换取稳定的高性能。

那resize方法做了什么捏?省流版:扩容2倍扩容,旧的table里的Entry,重新哈希放入新的table里。

private void resize() {
   
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    // 2倍当前长度大小
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    int count = 0;

    // 遍历旧table里的Entry,将Entry重新哈希,放入到新的table里
    for (int j = 0; j < oldLen; ++j) {
   
        Entry e = oldTab[j];
        if (e != null) {
   
            ThreadLocal<?> k = e.get();
            // 清理失效的Entry
            if (k == null) {
   
                e.value = null; 
            } else {
   
                // 哈希到新的槽位
                int h = k.threadLocalHashCode & (newLen - 1);
                // 开放地址法解决哈希冲突,如果这个节点有值了,就i+1找下一个槽位
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);    // h+1,寻找下一个槽位
                // 新table里,节点的赋值
                newTab[h] = e;
                // 旧table的有效Entry数量 + 1
                count++;
            }
        }
    }

    // 设置新的扩容阈值(数组长度的 2/3)
    setThreshold(newLen);
    // 修改为新的table的size
    size = count;
    // 指向新的table
    table = newTab;
}

// 设置新的扩容阈值
private void setThreshold(int len) {
   
    threshold = len * 2 / 3;
}

Get流程

image.png

get方法入口

public T get() {
   
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
   
        // 根据当前线程的ThreadLocal获取Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
   
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 当前线程没有ThreadLocalMap
    return setInitialValue();
}

获取Entry,getEntry方法。

private Entry getEntry(ThreadLocal<?> key) {
   
    // 获取槽位
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    // 如果这个槽不为null 且 这个槽位Entry的key是当前的ThreadLocal
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);    // 当前槽找不到这次要获取的key
}

如果找不到,执行getEntryAfterMiss方法。

为什么会找不到?

可能是真没有,也有可能是哈希冲突导致的槽位一直++,所以Entry被放入到了后面。

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
   
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
   
        ThreadLocal<?> k = e.get();
        // 找到了返回
        if (k == key)
            return e;
        // 清除过期的Entry
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);    // 获取下一个槽位
        // e变为下一个Entry
        e = tab[i];
    }
    // 实在找不到
    return null;
}

我们来看看,如果当前线程没有ThreadLocalMap,会怎么处理setInitialValue方法

private T setInitialValue() {
   
    // 初始value值,默认为null。子类可以重写,返回不同的初始值。
    T value = initialValue();
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocal。(上面有说过这个方法,就是t.threadLocals。)
    ThreadLocalMap map = getMap(t);
    // 这个和的set流程一样了。有就set初始值进去,没有就创建map。
    if (map != null) {
   
        map.set(this, value);
    } else {
   
        createMap(t, value);
    }

    // 返回初始值
    return value;
}

initialValue,默认返回null,子类可以重写。

protected T initialValue() {
   
    return null;
}

下面,主播为大家表演一手,子类重写。

// 子类重写
public class MyThreadLocal extends ThreadLocal {
   
    @Override
    protected Object initialValue() {
   
        return "aaaa";
    }
}

// 测试类 
public class MyThreadLocalTest {
   
    public static void main(String[] args) {
   
        MyThreadLocal myThreadLocalTest = new MyThreadLocal();
        String o = (String) myThreadLocalTest.get();
        System.out.println(o);    // 输出aaaa
    }
}

删除Entry

在ThreadLocal中存在2种删除方法。

探测删除(线性探测清理)

  • 对应方法expungeStaleEntry(int staleSlot)
  • 核心目标:清理当前过期的 Entry,并重新整理哈希表,解决因哈希冲突导致的 Entry 位置偏移问题。
  • 省流版流程:做了2件事情。

    1. 清空当前Entry 。
    2. 判断后续Entry是否需要清除,需要就清除,不需要的话,就重新哈希放到table里。
private int expungeStaleEntry(int staleSlot) {
   
    Entry[] tab = table;
    int len = tab.length;

    // 清理当前槽点的数据
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    Entry e;
    int i;
    // 从当前槽点开始向后遍历,判断每个Entry是否需要清空
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
   
        ThreadLocal<?> k = e.get();

        // 失效的Entry,就给它清除
        if (k == null) {
   
            e.value = null;
            tab[i] = null;
            size--;
        } else {
   
            // ====没有失效的Entry====
            // 重新哈希槽位,Entry重新放入table中
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
   
                tab[i] = null;
                // 哈希冲突,开放址法
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    // 返回停止的槽位
    return i;
}

⚠️ 探测性修复是有局限性的,并不会从当前要删除的槽点一直往后遍历到table的所有槽点,它也会在某个为null的槽点中停下来。(🤔不过话又说回来了,真扫了整个table,性能包炸的)

for (i = nextIndex(staleSlot, len);
     (e = tab[i]) != null;        // 当tab[i]为null会停止清除,即使tab[i+1]是过期的Entry
     i = nextIndex(i, len))

启发式清除(概率性扫描)

对应方法cleanSomeSlots(int i, int n)\
核心目标:以较低的成本扫描部分槽位,清理可能的过期 Entry,避免全表扫描的性能开销。

// 入参解释:
// - i,一个为null的槽位。
// - n,一般传的是table的长度。
private boolean cleanSomeSlots(int i, int n) {
   
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    // 一直修改len的值,如果len的默认值为16,16 -> 8 -> 4 -> 2 -> 0
    do {
   
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 当前节点是过期的
        if (e != null && e.get() == null) {
   
            n = len;
            removed = true;
            // 调用探测删除方法
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

while ((n >>>= 1) != 0) 是一种高效且巧妙的设计:

  1. 位运算控制循环次数:以 O(log n) 时间实现启发式扫描。
  2. 平衡性能与清理效果:避免全表扫描,但覆盖足够多的槽位。
  3. 自适应哈希表容量:天然适配 2 的幂次容量的哈希表。

Remove流程

上面介绍了Entry是怎么删除的,这里来看看,remove到底干了什么勒?😄

 public void remove() {
   
     ThreadLocalMap m = getMap(Thread.currentThread());    // 获取当前线程的ThreadLocalMap
     if (m != null)
         m.remove(this);
 }

具体的remove方法,我们总结下做了啥?

  1. 哈希开始的槽位,找到要删除的Entry
  2. 断开Entry对ThreadLocal key的弱引用(置为null)
  3. 探测式删除Entry。
private void remove(ThreadLocal<?> key) {
   
    Entry[] tab = table;
    int len = tab.length;
    // 获取开始的槽位
    int i = key.threadLocalHashCode & (len-1);
    // 可能出现哈希冲突,所以要一直向后找Entry。
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
   
        if (e.get() == key) {
   
            e.clear();    // 删除key的弱引用
            expungeStaleEntry(i);    // 上面介绍过勒,这个是探测式删除。
            return;
        }
    }
}

为什么会内存泄漏?

这就不得不提到我们的Entry了,我们可以看到,我们的Key是一个弱引用。

static class Entry extends WeakReference<ThreadLocal<?>> {
   

    Object value;

    Entry(ThreadLocal<?> k, Object v) {
   
        super(k);  // 调用父类 WeakReference 的构造函数,将 k 作为弱引用存储
        value = v;
    }
}

那么,弱引用会怎么样勒?来主播带大家回忆下,Java的四种引用:

强引用 (Strong Reference)

  • 清除时机:当对象没有任何强引用指向时,会在下一次GC时被回收。即使内存不足,也不会被回收(可能导致OOM)。
 Object obj = new Object();  // 强引用

软引用 (SoftReference)

  • 清除时机:当内存不足时(即将抛出OutOfMemoryError前),JVM会尝试回收。
SoftReference<Object> softRef = new SoftReference<>(new Object());

弱引用 (WeakReference)

  • 清除时机:只要发生垃圾回收,无论内存是否充足,对象都会被回收。
WeakReference<Object> weakRef = new WeakReference<>(new Object());

虚引用 (PhantomReference)

  • 清除时机: 对象被回收后,虚引用会被放入关联的ReferenceQueue,需手动处理。无法通过虚引用获取对象实例,仅用于追踪对象回收状态。
// 1. 必须绑定引用队列
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object heavyObject = new Object(); // 假设这是一个占用大量资源的对象

// 2. 创建虚引用(必须关联队列)
PhantomReference<Object> phantomRef = 
    new PhantomReference<>(heavyObject, queue);

// 3. 关键特性:无法通过虚引用获取对象
System.out.println(phantomRef.get()); // 输出 null ❌

// 4. 手动解除强引用(触发回收条件)
heavyObject = null;

// 5. 强制触发垃圾回收
System.gc();

// 6. 检查队列:当对象被回收后,虚引用会被加入队列
Reference<?> ref = queue.remove(500); // 阻塞500ms等待队列
if (ref == phantomRef) {
   
    System.out.println("对象已被回收,可执行后续清理操作 ✅");
    // 例如:若对象管理了堆外内存,可在此释放
}

ok,前面铺垫了这么久,让我们分析下,内存泄漏的原因:

Key 的弱引用特性

  • ThreadLocal 实例失去强引用时(例如 threadLocal = null),由于 Entry 的 key 是弱引用,GC 会回收该 ThreadLocal 实例,此时 Entry 的 key 变为 null
  • 问题Entryvalue 仍是强引用,无法被自动回收。
    image.png

线程长期存活

如果线程是线程池的核心线程(长期存活),它的 ThreadLocalMap 会一直存在。即使 key 被回收,value 仍然通过 Entry 的强引用保留在内存中。

代码模拟,没有及时remove,导致OOM:

public class ThreadLocalOOMExample {
   

    // 设置 JVM 参数:-Xmx64m -Xms64m (限制堆内存为 64MB)
    public static void main(String[] args) throws InterruptedException {
   
        ExecutorService executor = Executors.newFixedThreadPool(5); // 线程池复用线程
        for (int i = 0; i < 1000; i++) {
    // 提交 1000 个任务
            executor.submit(() -> {
   
                ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();
                try {
   
                    // 每个任务创建一个 1MB 的对象,存到 ThreadLocal 中
                    threadLocal.set(new BigObject());
                    // 模拟业务逻辑(不调用 remove())
                } finally {
   
                    // 此处故意不调用 threadLocal.remove()
                }
            });
            Thread.sleep(10); // 控制任务提交速度
        }
        executor.shutdown();
    }

    static class BigObject {
   
        // 每个对象占用 1MB 内存
        private final byte[] data = new byte[1024 * 1024];
    }
}

运行结果

Exception in thread "pool-1-thread-2" java.lang.OutOfMemoryError: Java heap space
    at ThreadLocalOOMExample$BigObject.<init>(ThreadLocalOOMExample.java:23)
    at ThreadLocalOOMExample.lambda$main$0(ThreadLocalOOMExample.java:15)
    ...

后话

聪明的你,是不是对ThreadLocal有更多的了解呢?

  • ThreadLocalMap的数据结构是怎么样
  • value是怎么set进去
  • 怎么get到我们set的value
  • 如何删除过期的Entry
  • 内存泄漏的可能原因

相信聪明的你一定有了答案(‾◡◝)

目录
相关文章
|
1天前
|
Java
【源码】【Java并发】【ConcurrentHashMap】适合中学体质的ConcurrentHashMap
本文深入解析了ConcurrentHashMap的实现原理,涵盖JDK 7与JDK 8的区别、静态代码块、构造方法、put/get/remove核心方法等。JDK 8通过Node数组+链表/红黑树结构优化并发性能,采用CAS和synchronized实现高效锁机制。文章还详细讲解了hash计算、表初始化、扩容协助及计数更新等关键环节,帮助读者全面掌握ConcurrentHashMap的工作机制。
26 6
|
2天前
|
缓存 安全 Java
【Java并发】【ConcurrentHashMap】适合初学体质的ConcurrentHashMap入门
ConcurrentHashMap是Java中线程安全的哈希表实现,支持高并发读写操作。相比Hashtable,它通过分段锁(JDK1.7)或CAS+synchronized(JDK1.8)实现更细粒度锁控制,提升性能与安全性。本文详细介绍其构造方法、添加/获取/删除元素等常用操作,并对比JDK1.7和1.8的区别,帮助开发者深入理解与使用ConcurrentHashMap。欢迎关注,了解更多!
25 3
【Java并发】【ConcurrentHashMap】适合初学体质的ConcurrentHashMap入门
|
4天前
|
人工智能 安全 Java
智慧工地源码,Java语言开发,微服务架构,支持分布式和集群部署,多端覆盖
智慧工地是“互联网+建筑工地”的创新模式,基于物联网、移动互联网、BIM、大数据、人工智能等技术,实现对施工现场人员、设备、材料、安全等环节的智能化管理。其解决方案涵盖数据大屏、移动APP和PC管理端,采用高性能Java微服务架构,支持分布式与集群部署,结合Redis、消息队列等技术确保系统稳定高效。通过大数据驱动决策、物联网实时监测预警及AI智能视频监控,消除数据孤岛,提升项目可控性与安全性。智慧工地提供专家级远程管理服务,助力施工质量和安全管理升级,同时依托可扩展平台、多端应用和丰富设备接口,满足多样化需求,推动建筑行业数字化转型。
36 5
|
5天前
|
存储 安全 Java
【Java并发】【原子类】适合初学体质的原子类入门
什么是CAS? 说到原子类,首先就要说到CAS: CAS(Compare and Swap) 是一种无锁的原子操作,用于实现多线程环境下的安全数据更新。 CAS(Compare and Swap) 的
40 15
【Java并发】【原子类】适合初学体质的原子类入门
|
5天前
|
Java
【源码】【Java并发】【LinkedBlockingQueue】适合中学体质的LinkedBlockingQueue入门
前言 有了前文对简单实用的学习 【Java并发】【LinkedBlockingQueue】适合初学体质的LinkedBlockingQueue入门 聪明的你,一定会想知道更多。哈哈哈哈哈,下面主播就...
34 6
【源码】【Java并发】【LinkedBlockingQueue】适合中学体质的LinkedBlockingQueue入门
|
5天前
|
安全 Java
【Java并发】【LinkedBlockingQueue】适合初学体质的LinkedBlockingQueue入门
前言 你是否在线程池工具类里看到过它的身影? 你是否会好奇LinkedBlockingQueue是啥呢? 没有关系,小手手点上关注,跟上主播的节奏。 什么是LinkedBlockingQueue? ...
29 1
【Java并发】【LinkedBlockingQueue】适合初学体质的LinkedBlockingQueue入门
|
6天前
|
安全 Java
【源码】【Java并发】【ArrayBlockingQueue】适合中学者体质的ArrayBlockingQueue
前言 通过之前的学习是不是学的不过瘾,没关系,马上和主播来挑战源码的阅读 【Java并发】【ArrayBlockingQueue】适合初学体质的ArrayBlockingQueue入门 还有一件事
39 5
【源码】【Java并发】【ArrayBlockingQueue】适合中学者体质的ArrayBlockingQueue
|
12天前
|
Java 中间件 调度
【源码】【Java并发】从InheritableThreadLocal和TTL源码的角度来看父子线程传递
本文涉及InheritableThreadLocal和TTL,从源码的角度,分别分析它们是怎么实现父子线程传递的。建议先了解ThreadLocal。
48 4
【源码】【Java并发】从InheritableThreadLocal和TTL源码的角度来看父子线程传递
|
19天前
|
数据采集 存储 网络协议
Java HttpClient 多线程爬虫优化方案
Java HttpClient 多线程爬虫优化方案
|
1月前
|
存储 网络协议 安全
Java网络编程,多线程,IO流综合小项目一一ChatBoxes
**项目介绍**:本项目实现了一个基于TCP协议的C/S架构控制台聊天室,支持局域网内多客户端同时聊天。用户需注册并登录,用户名唯一,密码格式为字母开头加纯数字。登录后可实时聊天,服务端负责验证用户信息并转发消息。 **项目亮点**: - **C/S架构**:客户端与服务端通过TCP连接通信。 - **多线程**:采用多线程处理多个客户端的并发请求,确保实时交互。 - **IO流**:使用BufferedReader和BufferedWriter进行数据传输,确保高效稳定的通信。 - **线程安全**:通过同步代码块和锁机制保证共享数据的安全性。
86 23