做一个Java Json压缩工具类,核心思路其实不复杂:先把JSON转成UTF-8字节,再用通用压缩算法把字节流压一遍,需要的时候再解压还原成JSON字符串。但就是这么个不起眼的工具,在实际项目里能解决不少让人头疼的问题。接口响应体太大、消息中间件单条消息超限、缓存Key塞太长、日志文件把磁盘撑爆,这些场景我全都遇到过。如果你也在做Java后端开发,或者正在优化接口性能和存储成本,这篇内容应该能帮上忙。
JSON本身是文本格式,为了可读性,键名、逗号、冒号、缩进全都占据空间,而且同一个结构和键名还会反复出现。这种冗余天然适合压缩。压缩工具类就是把冗余做掉封装:调用方只管把JSON字符串交进来,工具类保证返回的是一个体积大幅缩小的字节数组;对端拿到之后解压还原,语义完全一致。这个能力不需要引入重量级框架,JDK自带的Deflater就能搞定,难的是怎么在压缩率、速度和工程易用性之间找到平衡。
我这次分享的不是一个单纯粘贴就能用的代码片段,而是一套完整的取舍思路。包括底层算法怎么选、压缩级别怎么调、集成到框架层怎么做、线上会遇到哪些坑。你可以直接照抄代码,也可以挑里面的设计思路去改造成自己的实现。
1. 为什么需要做Json压缩工具类
1.1 先从JSON体积说起
很多人对JSON体积膨胀没有直观感受,觉得不就是多一点空格和引号吗?等数据量级上来,问题就完全不一样了。
我举个例子。一条订单数据,如果后端做格式化输出,长这样:
json复制{
"orderId": "202606131729380001",
"userName": "zhangsan",
"skuList": [
{
"skuId": "SKU-1001",
"skuName": "无线鼠标",
"quantity": 1
}
]
}
同样一条数据,去掉所有空格、换行、美化缩进之后,大概能瘦下来20%左右。可如果这条数据里塞了100个商品、1000个历史订单,或者视频网站的一整屏feed流,那问题就不是缩进那点空间了,而是skuName这种键名出现了几千次,每次都在重复占用字节。
更麻烦的是,很多接口文档直接要求返回格式化JSON方便前端调试,这种“装饰性可读性”在传输和存储环节付出了巨大代价。我见过一个内部接口,单条JSON返回体有4MB多,里面大量字段是空数组、null值、重复路径,前端真正用到的不到十分之一。后来做接口性能排查,发现序列化本身只有几十毫秒,但网络传输、网关buffer、日志采集全都被这4MB拖着走。把JSON压缩后再投递,响应时间直接降了一个数量级。
1.2 压缩的价值点到底在哪
压缩工具类不是用来“秀技术”的,它解决的问题非常实在。
- 网络带宽:同样的业务数据,压缩后传输体积可能只有原来的15%到30%,对带宽受限场景尤其重要。
- 存储成本:Redis、MySQL、ES、日志文件,存的字节越小,占用的内存和磁盘就越少。当数据量达到上亿条时,压缩率多一个百分点都是钱。
- 消息队列单条限制:很多MQ对单条消息有体积上限,比如Kafka的
message.max.bytes默认1MB。业务字段一多,一条消息很容易超限。压缩之后再发送,是最快的绕过方案。 - 应用启动和序列化性能:对频繁读写的对象,如果缓存里存的是大体积JSON,每次读出来都要做IO,CPU也容易吃紧。压缩后IO少、反序列化前先解压,整体耗时往往是下降的。
这也是为什么很多Java面试题喜欢问“如何压缩JSON”,因为这个问题背后考察的是对网络IO、内存占用、序列化设计等多个方面的理解,而不仅仅是背一个API。
1.3 哪种压缩方案更适合JSON
常见方案有好几类,我先把结论放前面:如果追求零依赖,用JDK自带的Deflater。如果传输过程需要HTTP标准支持,用GZIP。如果对压缩速度极其敏感,可以考虑Snappy、LZ4或zstd,但需要引入第三方jar包。
Deflater是JDK自带的ZIP压缩核心类,支持原始deflate和zlib格式,压缩率和GZIP差不多,但没有GZIP的文件头尾,更适合“压缩一段数据再解压一段数据”的纯内存场景。GZIP本质上是加了文件头的deflate,优点是HTTP协议原生支持,浏览器和很多客户端都能自动解压。Snappy和LZ4把压缩速度放在第一位,适合在局域网内传输、对延迟敏感的服务间调用。zstd压缩比更高,但引入native库后部署环境要多操一份心。
我最终默认选型是Deflater加一个可选的GZIP方法。原因是大部分后端起服务之间的调用并不需要HTTP的Content-Encoding机制,用原始deflate在字节级上可以省掉那十几字节的文件头,不需要额外引入第三方依赖,部署也干净。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具类整体设计与压缩策略
2.1 设计思路:分层解耦
压缩工具类的核心设计原则是“无状态、易替换”。我推荐把它做成一个静态方法为主的工具类,不持有任何实例状态,传入JSON字符串就返回字节数组,传入字节数组就还原字符串。
为什么强调无状态?因为压缩涉及CPU计算和内存分配,如果类里维护了类似ByteArrayOutputStream的复用缓存,在多线程场景下就必须处理并发问题。静态无状态方法虽然每次都会创建新的Deflater实例,但Deflater本身比较轻量,在绝大多数业务场景下这个开销可以忽略。真到高并发再去做池化,不要一开始就把复杂度引进来。
类职责上,我一般拆成三层:
- 入口层:接收
String、byte[]、Base64 String,负责格式转换。 - 压缩层:封装
Deflater和GZIPOutputStream,只负责字节流的压缩和解压。 - 策略层:控制是否启用压缩、压缩级别、阈值判断。
这样拆完,后面如果要换zstd,只需要改压缩层,入口层和策略层基本不用动。
2.2 压缩算法怎么选
选型要看你面对的是数据特征和部署约束,没有绝对最好的算法,只有最适合当前场景的算法。
先说压缩率。JSON属于文本数据,有大量重复的键名、标签值、数字字符串,所以对字典类算法特别友好。Deflater的BEST_COMPRESSION级别在这种数据上通常能把体积压到20%左右。GZIP的默认级别和Deflater.DEFAULT_COMPRESSION接近,压缩率稍差一点,但因为多了标准头尾,方便外部工具识别。
再说速度。BEST_SPEED级别的压缩耗时通常只有BEST_COMPRESSION的十分之一甚至更少,但压缩率会差一些。对实时性要求极高的接口,我会建议用BEST_SPEED;对离线日志归档、冷数据存储,直接用BEST_COMPRESSION,耗时间是值得的。
第三方方案里,Snappy和LZ4的压缩速度极快,但压缩率通常不如deflate。zstd在中高压缩级别下可以做到比deflate更高的压缩率,速度也还行,就是需要额外jar包和native库。我的建议是前期先别引入第三方,JDK原生的Deflater足够应对90%的JSON压缩场景。
2.3 文本预处理:压缩前的瘦身
很多人忽略一个事:压缩前对JSON字符串做一次“瘦身”,能让压缩算法更专注于消除结构化冗余。
最简单有效的预处理是移除所有不影响语义的空格。但这里有一个大坑:不能简单用正则把所有空白替换掉。因为JSON字符串值里完全可能包含空格,比如{"title": "hello world"},你把所有空格删了,hello world就变成helloworld了,语义直接破坏。
正确做法是用JSON解析库重新序列化一次。Gson或Jackson都可以,把原始JSON解析成对象,再以无空格、无美化的序列化方式重新输出一行紧凑JSON。这一步虽然多做了解析和重写,但好处很明显:后续的压缩算法不需要去处理那些格式空白的冗余,可以把压力集中在真正重复的键名和结构上。
对于要求极致压缩的场景,还可以做键名映射。把userName替换成u,skuList替换成s,在JSON头部附带一个映射字典。这个方案压缩率非常可观,但会牺牲可读性,也要解决映射字典的存储和版本问题。我自己是把它放在工具类的“扩展能力”里,默认不开启,只有项目里真的对体积极其敏感才用。
3. 核心实现:手写一个可用的JsonCompressUtil
3.1 基于JDK自带Deflater的实现
直接用Deflater做JSON压缩,比大多数人想象中简单。完整实现如下:
java复制import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
public final class JsonCompressUtil {
private JsonCompressUtil() {
}
/**
* 压缩JSON字符串,返回原始deflate字节数组
*/
public static byte[] compress(String json) throws Exception {
if (json == null || json.isEmpty()) {
return new byte[0];
}
byte[] input = json.getBytes(StandardCharsets.UTF_8);
// 第二个参数nowrap设为true,表示使用raw deflate,不带zlib头
Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION, true);
deflater.setInput(input);
deflater.finish();
ByteArrayOutputStream bos = new ByteArrayOutputStream(input.length / 2);
byte[] buf = new byte[8192];
while (!deflater.finished()) {
int len = deflater.deflate(buf);
bos.write(buf, 0, len);
}
deflater.end();
return bos.toByteArray();
}
/**
* 解压,还原JSON字符串
*/
public static String decompress(byte[] data) throws Exception {
if (data == null || data.length == 0) {
return "";
}
Inflater inflater = new Inflater(true);
inflater.setInput(data);
ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length * 2);
byte[] buf = new byte[8192];
while (!inflater.finished()) {
int len = inflater.inflate(buf);
if (len == 0) {
if (inflater.needsInput()) {
// 数据不完整,避免死循环
break;
}
}
bos.write(buf, 0, len);
}
inflater.end();
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
}
}
这段代码里有几个细节值得解释。
第一,new Deflater(Deflater.BEST_COMPRESSION, true)的第二个参数nowrap非常关键。传true表示会用“raw deflate”格式,不生成zlib头尾。如果你在解压时用了new Inflater(true),两边格式必须一致,否则解压直接报错。我默认用true,是因为纯内存传输场景下,zlib那十几字节的头尾没有意义,能省就省。
第二,deflater.finished()是判断压缩结束的标准,但这里有个小陷阱。如果输入为空或者数据极其特殊,deflate()可能第一次就返回0,而finished()仍然为false,导致循环空转。所以在入口处先判断了空字符串,算是一道防线。
第三,deflater.end()必须调用。这个API释放的是native层资源,不调用在长周期应用里会积累内存问题。JDK 9以后Deflater实现了AutoCloseable,你也可以用try-with-resources,但老项目还是手动end()更稳妥。
3.2 兼容GZIP格式的写法
某些场景下,你需要让压缩后的数据被HTTP客户端自动解压,或者被日志分析工具识别。这时候GZIP格式更合适。
java复制import java.io.ByteArrayOutputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public final class JsonGzipUtil {
public static byte[] compressGzip(String json) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
gzip.write(json.getBytes(StandardCharsets.UTF_8));
}
return bos.toByteArray();
}
public static String decompressGzip(byte[] data) throws IOException {
ByteArrayInputStream bis = new ByteArrayInputStream(data);
try (GZIPInputStream gzip = new GZIPInputStream(bis)) {
ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length * 2);
byte[] buf = new byte[8192];
int len;
while ((len = gzip.read(buf)) != -1) {
bos.write(buf, 0, len);
}
return bos.toString(StandardCharsets.UTF_8.name());
}
}
}
GZIP版本的优点是对外标准、通用,缺点也很明显:相比raw deflate多了固定格式头尾,压缩率略低一点。适合开放平台API、对接浏览器端、承接日志采集等场景。服务间内部调用如果能控制协议,raw deflate更合适。
3.3 配合Jackson和Gson使用
压缩工具类单独用价值有限,通常要配合JSON序列化器一起用,形成一个完整的读写链路。
以Jackson为例:
java复制ObjectMapper mapper = new ObjectMapper();
// 序列化订单对象,得到紧凑JSON
String json = mapper.writeValueAsString(order);
// 压缩
byte[] compressed = JsonCompressUtil.compress(json);
// 存储到Redis或者发送到消息队列
redisTemplate.opsForValue().set(key, compressed);
// 读取时先解压,再反序列化
byte[] cached = redisTemplate.opsForValue().get(key);
String cachedJson = JsonCompressUtil.decompress(cached);
OrderVO orderVO = mapper.readValue(cachedJson, OrderVO.class);
这里有一个容易忽略的问题:ObjectMapper默认序列化出来的是不含格式空白的紧凑JSON,这已经是最合适压缩的输入了。如果你在这之前调用了writerWithDefaultPrettyPrinter()做美化输出,再拿去压缩,等于把一堆缩进空格也压进去,浪费CPU。所以工程上要有纪律:进入缓存和消息队列之前,永远不要做美化序列化,压缩工具类不负责帮你清理前面格式化造成的垃圾。
3.4 压缩结果格式约定
工具类做到后面,强烈建议在压缩字节流里预留一个版本标识。我自己的实现会在压缩结果的最前面插入一个字节,用来标识压缩算法类型,比如0x01代表raw deflate,0x02代表GZIP,0x03代表zstd。
为什么这么做?因为线上数据是有生命周期的。你今天用raw deflate压了一批数据存到Redis,三个月后才发现某个老版本数据解压失败,如果字节流里没有版本头,你根本不知道那批数据是用什么算法压的。加一个头字节,解压时先读标识,再路由到对应解压器,老数据也能安全兼容。代价就是多一字节的空间,几乎可以忽略。
这个方案同样解决了布式服务里多版本并存的问题。服务A还在用老版本工具类写数据,服务B已经升级到新算法,版本头能保证两边互相解压时快速识别并处理。我见过不少团队不做版本头,升级算法后线上数据全部作废,最后只能用脚本离线重算,那个教训挺深刻的。
4. 参数调优与线上实测
4.1 压缩级别怎么选
Deflater有几个经典压缩级别,它们的差异非常明显。
| 级别 | 压缩比 | CPU耗时 | 适用场景 |
|---|---|---|---|
BEST_SPEED |
中等,通常能压到原始体积的35%-50% | 极低 | 高并发接口、实时缓存读写 |
DEFAULT_COMPRESSION |
较高,约25%-40% | 中等 | 多数内部场景 |
BEST_COMPRESSION |
最高,约15%-30% | 高,可能慢5-10倍 | 冷数据存储、离线任务、日志归档 |
压缩级别本质是时间换空间。我建议不要在所有地方用BEST_COMPRESSION,因为在高QPS场景下,多出来的压缩耗时可能会抵消省下的带宽收益。更合理的做法是:小数据、高并发场景用BEST_SPEED;大数据、低频写、频繁读的场景用BEST_COMPRESSION。
4.2 一次真实体积对比
我在一台4核8G的机器上做过一次粗测,测试数据是从商品库导出的JSON,包含100条商品信息,每条商品有SKU、价格、库存、图片地址数组等字段,美化格式原始体积约2.4MB。
测试结果大致如下:
| 方案 | 压缩后体积 | 压缩耗时 | 说明 |
|---|---|---|---|
| 美化JSON,不压缩 | 2.4MB | 0ms | 原始输入 |
| 去掉所有空白后的紧凑JSON | 1.9MB | 约15ms | 纯文本瘦身 |
Deflater.BEST_SPEED raw格式 |
约680KB | 约8ms | 体积压缩率约72% |
Deflater.BEST_COMPRESSION raw格式 |
约320KB | 约180ms | 体积压缩率约87% |
GZIP默认级别 |
约350KB | 约40ms | 略逊于BEST_COMPRESSION |
这个结果挺能说明问题。BEST_SPEED和BEST_COMPRESSION相比,压缩率下降了大概一倍体积,但耗时差了20多倍。如果接口本身QPS很高,这180ms就很不划算。反过来,如果数据是写一次、读很多次,那BEST_COMPRESSION节省下来的存储和读取IO完全值得。
4.3 集成到框架层的实践
工具类在业务代码里一个一个调用,容易漏。更优雅的做法是集成到框架层,让调用方无感知。
比如在Spring的ResponseBodyAdvice里统一对响应体做压缩,或者在自定义Redis的RedisSerializer<T>里,序列化时压缩、反序列化时解压。这样业务代码根本不需要关心压缩逻辑,只管序列化对象。
我实际用的是在Redis序列化器里接入压缩。大概思路是:value序列化时,先用Jackson把对象转成紧凑JSON,判断字节数大于某个阈值(比如1KB)后才压缩,小于阈值直接存储。因为工具类是无状态的,序列化器可以安全调用静态方法,不会有并发问题。线上跑下来,Redis的used_memory下降了将近60%,读取耗时反而略有下降。
框架层集成唯一要小心的是藏得太深。一旦压缩逻辑藏在框架层,日后排查数据问题时要能追溯。我的建议是预留一个开关配置,比如json.compact.enabled=true,压测时对比开启前后的CPU和内存指标,确认收益后再全套上线。不要一上来就全链路开压缩,出问题不好回滚。
5. 高频问题与避坑实录
5.1 解压出来的字符串乱码
这个是我见过最多的报错,症状是解压后字符串变成乱码或抛出UnsupportedEncodingException。
原因几乎都是字符集不统一。压缩时用json.getBytes(),这个方法使用的是JVM默认字符集。如果服务器系统是GBK,Windows上开发环境是UTF-8,压缩端和解压端字符集不一致,解压出来必然乱码。解决方案很简单:所有getBytes()和new String()都显式传StandardCharsets.UTF_8,不要依赖默认字符集。这也是我代码里反复写StandardCharsets.UTF_8的原因。
5.2 压缩率反而变大
有人把JSON压缩后,发现数据反而比原始JSON还大,第一反应是算法有问题。其实不是,问题往往出在数据本身上。
如果原始JSON只有几十字节,比如{"a":1,"b":2},任何通用压缩算法都很难从里面压出东西,反而要付出固定格式头、字典初始化等额外开销,结果就是越压越大。这种情况应该直接不压缩。我一般设置一个阈值,原始JSON字节数小于1KB就不执行压缩,省CPU也避免浪费空间。
还有另一个容易忽略的点:如果把压缩后的byte[]用new String(bytes, "UTF-8")转成字符串再存数据库或发送,等于把二进制数据做了一次错误的字符编码,体积膨胀且解压必乱。正确做法是用Base64编码,但Base64会带来约33%的体积膨胀。所以如果你对体积极其敏感,就别走字符串传输,直接使用二进制body或者把byte[]写到MessageConverter里,避免Base64这层额外开销。
5.3 流未关闭导致的内存问题
Deflater和Inflater都涉及native内存,如果创建后没有调用end(),长期运行的服务会慢慢吃掉内存,最终出现OutOfMemoryError,而且堆内存dump还不一定能直接看出来问题。
我在规范里强制要求:每次创建Deflater必须紧跟end(),Inflater同理。代码里只要使用了ByteArrayOutputStream,也应该在finally块里关闭。如果你用的是JDK 9以上,可以这样写:
java复制try (Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION, true)) {
// 使用deflater
}
这样结束时就自动释放资源,不容易漏。老项目里的代码建议全部检查一遍,凡是手动new Deflater的,都要确认有没有对应的end()。
5.4 解压死循环或卡死
我自己踩过的一个坑是解压时只写了while (!inflater.finished()),结果在输入数据不完整的情况下,inflate()返回0,finished()永远是false,循环直接把CPU卡死。
更健壮的写法是判断返回长度和needsInput()状态:
java复制while (!inflater.finished()) {
int len = inflater.inflate(buf);
if (len == 0) {
if (inflater.needsInput()) {
// 输入被消费完但还没结束,说明数据不完整
break;
}
// 防御极端情况
break;
}
bos.write(buf, 0, len);
}
另外,对从外部传入的压缩数据,必须限制解压后的最大体积,防止“解压炸弹”。压缩比可以达到1:10甚至更高,一个几百KB的压缩包解出来可能是几十MB。在内存受限的容器里,这种攻击会导致OOM。我一般会在解压入口加一个MAX_DECOMPRESS_SIZE常量,解压过程中累计写入长度超过上限就直接抛异常,宁可不处理也不能把内存打爆。
5.5 接口兼容性:版本升级后老数据解不了
工具类升级算法版本时,最大的风险不是代码报错,而是线上已经存在的老数据解不开。这个问题在Redis缓存、数据库归档、日志文件里会反复出现。
解决思路就是我在3.4里说的版本头。协议设计上,第一字节留给算法标识,后续字节才是真正的压缩数据。解压时先读第一字节,按标识选择解压器。如果解压器缺失,就明确抛异常提示“不支持的压缩格式版本”。这样升级过程是可控的,老数据也随时能解,不存在“避开没人知道怎么处理”的尴尬状态。
6. 经验总结与后续扩展
6.1 一个容易被忽略的小技巧
真正做极致压缩时,可以针对JSON做键名映射。比如一张订单对象里有orderId、userId、createTime、skuList这些键,压缩前统一映射成a、b、c、d等短键,同时在压缩结果的头部存一份映射字典。这样deflate算法处理的对象就变成了一堆极短键名加值,整体压缩率能进一步明显提升。
但代价是JSON本身彻底失去可读性,排查问题时需要先解码还原。所以我建议只在存储大对象、且不需要人工直读JSON的冷数据场景使用。工具类里可以做成可选策略,不要默认打开。
6.2 什么时候不建议压缩
压缩不是银弹。对于体积小于1KB的短JSON,不压缩最合适;对于本身已经用gzip压过的文件或图片数据,再做一层JSON压缩收益微乎其微;对于开发调试日志、需要人类直读的告警信息,压缩反而添乱。另外,如果CPU资源极度紧张,但带宽和存储还挺充裕,那压缩的收益也需要重新评估。
我自己在实际项目里的体会是:压缩工具类最好的状态是让调用方无感知。当一个后端服务从“缓存大JSON”变成“缓存压缩JSON”,从“发超长消息”变成“发压缩消息”,性能数据自己会说明问题。你不需要在每个Controller里写压解的代码,只需要在正确的层接入一次,剩下的交给时间和监控数据去验证。这套工具类从最早的手写方法,到现在收敛成几十行稳定的静态工具,最大的功劳不是代码本身,而是那些被提前规避掉的坑。
