登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java 虚拟线程 pinned同步块为什么让虚拟线程失去并发优势怎样在批处理代码中减少额外分配

来源:17golang原创

时间:2026-11-10 04:35:42 200浏览 收藏

同步块导致虚拟线程占住载体的结论必须加上版本条件:JDK 21–23 中,持有 synchronized 监视器时阻塞可能出现 pinning;JDK 24 引入 JEP 491 后,普通监视器相关阻塞不再因这一原因固定载体。但共享锁造成的业务串行仍然存在。减少批处理分配则要从对象所有权入手,让缓冲区在一个批次内复用,而不是让所有虚拟线程共用可变对象。

官方变更地址:https://www.oracle.com/java/technologies/javase/24-relnote-issues.html。OpenJDK 提案地址:https://openjdk.org/jeps/491。下面讨论这一已落地的变化怎样影响现有代码,不把它当作刚发布的消息。

1. 先按 JDK 版本区分 pinning 与锁竞争

虚拟线程借用载体线程执行;监视器保护共享状态。旧版本中,虚拟线程在同步块内进行某些阻塞调用时,无法像普通可卸载等待那样释放载体。高并发下,有限载体被长时间占用,就会削弱虚拟线程适合等待密集任务的优势。同步块本身不是错误,关键是版本、持锁范围与阻塞时长。

区分监视器互斥与载体占用
区分监视器互斥与载体占用;静态关系说明图,非截图或运行证据。
情况应当判断什么
JDK 21–23 持监视器阻塞可能固定载体,优先关注频繁的长等待
JDK 24 普通 synchronized 阻塞JEP 491 已改变监视器相关固定行为
所有版本共用一把业务锁锁内工作仍互斥,吞吐可能受串行段限制
主要时间用于 CPU 运算虚拟线程不增加可用 CPU 算力

原生代码等其他限制不能由“synchronized 已改善”推导为全部消失。实际发行版有回移差异时,应以该构建说明为准。

2. 用短临界区隔离批处理的阻塞工作

如果锁只保护提交计数,就让编码、等待与提交分开:批次任务持有输入列表和私有缓冲,编码函数写入私有缓冲;提交锁只保护共享计数。以下示例可用于 Java 21 及以上环境,保存为 BatchPacking.java。本文没有执行该程序,也不提供虚构的性能数字。

import java.util.List;

public class BatchPacking {
    private final Object commitLock = new Object();
    private long committedChars;

    public void pack(List rows) throws InterruptedException {
        // 每个批次独占一个缓冲区,避免每行都新建构建器
        StringBuilder buffer = new StringBuilder(4096);
        for (String row : rows) {
            buffer.setLength(0);
            encode(row, buffer);
            int chars = buffer.length();

            // 模拟阻塞边界,实际项目替换为有期限的客户端调用
            Thread.sleep(1);

            // 仅提交共享状态,阻塞调用不持有提交锁
            synchronized (commitLock) {
                committedChars += chars;
            }
        }
    }

    private static void encode(String row, StringBuilder out) {
        // 追加到已有对象;本例只演示缓冲所有权,不定义网络协议
        out.append("row=").append(row);
    }

    public long committedChars() {
        // 读取与更新使用相同监视器,保证可见性
        synchronized (commitLock) {
            return committedChars;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        BatchPacking job = new BatchPacking();
        Thread task = Thread.ofVirtual().start(() -> {
            try {
                job.pack(List.of("alpha", "beta", "gamma"));
            } catch (InterruptedException e) {
                // 保留中断信号,让调用方能够结束批次
                Thread.currentThread().interrupt();
            }
        });
        task.join();
        System.out.println(job.committedChars());
    }
}
# 使用目标 JDK 编译、运行相同源文件,避免混用运行时
javac BatchPacking.java
java BatchPacking

该程序按编码后的字符数累计,语义上总计为 26。Thread.sleep 仅模拟等待;它不是网络调用的性能替身,也不能据此计算线上提速倍数。若业务要求外部请求与状态更新具有原子性,不能直接把请求移出锁;需要版本号、事务或幂等提交等适合业务的并发协议。

3. 将缓冲区限定为批次私有对象

私有缓冲是每次 pack 调用创建一次的 StringBuilder;setLength(0) 清除逻辑内容以供下一行使用。这样减少的是逐行创建构建器的机会,未声称整个编码过程零分配。输入 String、列表、虚拟线程以及真实客户端内部仍可能产生对象。

批次私有缓冲与共享提交关系
批次私有缓冲与共享提交关系;静态关系说明图,非截图或运行证据。

不要把它改成静态共享构建器,否则多个线程会相互覆盖;给它加一把全局锁又会让编码串行。也不要为了复用大缓冲盲目迁移到 ThreadLocal:大量短生命周期虚拟线程可能各自持有一份大对象,失去平台线程池长期复用时的成本优势。

4096 是示例初始容量而非推荐阈值。缓冲扩容后,清空长度不会主动缩小容量;遇到异常大行,可以按业务上限重新创建缓冲或拒绝超限输入。若异步客户端保留传入对象,必须等待消费结束或传递独立不可变副本,不能立即清空复用。若需要字节数,应以明确字符集编码后的长度统计,字符数不能充当传输字节数。

4. 比较方案时同时观察等待与分配

对旧方案、短临界区方案和升级后的运行时使用相同数据量、等待分布、连接池限制和并发上限。分别观察完成吞吐、尾部耗时、锁竞争、载体占用、分配速率、GC 与峰值存活内存。JDK 21–23 可结合 JFR 的虚拟线程固定事件定位长期等待;JDK 24 的监视器实现已变化,不能因为旧固定事件减少,就认定锁竞争也减少。

分配优化和 pinning 优化应分别归因:前者涉及每行对象与缓冲保留,后者涉及阻塞期间的调度限制。升级 JDK 仍须兼顾依赖和运维兼容;改变锁实现也必须保持原有互斥、可见性与异常释放语义。

升级后还要全部替换 synchronized 吗?

不需要机械替换。先依据目标运行时和等待热点判断,短小的共享状态更新可以继续使用监视器。

复用 StringBuilder 是否必然更快?

不保证。小对象可能被编译器优化;大缓冲保留也可能增加内存成本。用实际负载比较分配与存活内存。

一个虚拟线程处理无限大的批次合适吗?

应按资源预算划分批次,控制输入保留、客户端并发与任务期限。轻量线程不代表数据库连接或下游服务也无限可用。

虚拟线程原始设计及线程本地资源说明:https://openjdk.org/jeps/444;Oracle 核心库指南:https://docs.oracle.com/en/java/javase/22/core/java-core-libraries-developer-guide.pdf。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>