2024年1月

个人介绍

李昂
高级数据研发工程师
Apache Doris & Hudi Contributor

业务背景

部门成立早期, 为了应对业务的快速增长, 数仓架构采用了最直接的Lambda架构

  1. 对数据新鲜度要求不高的数据, 采用离线数仓做维度建模, 采用每小时调度binlog+每日主键归并的方式实现T+1数据更新
  2. 对数据时效性要求比较高的业务, 采用实时架构, 保证增量数据即时更新能力, 另一方面, 为了保证整体上线效率, 存量数据采用离线SQL处理, 以提高计算吞吐量

Lambda整体架构如下


此时的架构存在以下缺陷

  1. **逻辑冗余 : **同一个业务方案, 往往有离线与实时两套开发逻辑, 代码复用性低, 需求迭代成本大, 任务交接、项目管理复杂
  2. **数据不一致 : **应用层数据来源有多条链路, 在处理逻辑异构的情况下, 存在数据不一致的问题, 且问题排查成本大, 周期长
  3. **数据孤岛 : **随着业务增长, 为了应对离线批处理、OLAP分析、C端高并发点查等场景, 引入的存储引擎越来越多, 存在数据孤岛

基于上述Lambda架构存在的缺陷, 我们希望对其作出改进, 实现以下目的

  1. 流批一体 :
    同一个业务方案, 可以由一套代码逻辑或者核心逻辑一致的SQL实现
  2. 数据整合 :
    统一离线批处理与OLAP分析的数据存储口径, 同时查询支持SparkSQL与Doris-Multi-Catalog, 打破数据孤岛

选型调研

对比项 \ 选型 Apache Hudi Apache Iceberg Apache Paimon
增量实时upsert支持&性能 较好
存量离线insert支持&性能 较好
增量消费 支持 依赖Flink State 支持
社区活跃度 Fork 2.4K , Star 4.6K Fork 1.8K , Star 4.9K Fork 0.6K , Star 1.4K
Doris-Multi-Catalog支持 1.2+ 支持 1.2+ 支持 2.0+ 支持

综合考虑以下几点

  1. 项目成熟度 :
    社区活跃度、国内Committer数量、国内群聊活跃度、各公司最佳实践发文等
  2. 数据初始化能力 :
    考虑到需要对历史项目进行覆盖, 需要考虑存量数据写入能力
  3. 数据更新能力 :
    一方面是数据根据PrimaryKey或者UniqueKey的实时Upsert、Delete性能, 另一方面是Compaction性能
  4. CDC :
    如果需要分层处理, 则要求数据湖作为Flink Source时有产生撤回流的能力

我们最终选定使用Apache Hudi作为数据湖底座

方案选型

业务痛点

实时流 join 是事实数仓的痛点之一, 在我们的场景下, 一条事实数据, 需要与多个维度的数据做关联, 例如一场司法拍卖, 需要关联企业最新名称、董监高、企业性质、上市信息、委托法院、询价评估机构等多个维度;一方面, 公司与董监高是1:N的对应关系, 无法实现一条写入, 多条更新; 另一方面, 企业最新名称的变更, 可能涉及到历史冷数据的更新

方案设计

FlinkSQL+离线修复

方案描述
通过FlinkSQL实现增量数据的计算, 每日因为状态TTL过期或者lookup表变更而没有被命中的数据, 通过凌晨的离线调度进行修复

优点

  1. SQL开发 :
    便于维护
  2. 架构简洁 :
    不涉及其他非必要组件

缺点

  1. **批流没有完全一体 : **同一逻辑仍然并存FlinkSQL与SparkSQL两种执行方式
  2. **维护Flink大状态 : **为保证数据尽可能的join到, 需要设置天级甚至周级的TTL
  3. **时效性下限较低 : **最差仍然可能存在T+1的延迟

MySQL中间表

方案描述
使用MySQL实现数仓分层, 为每张上游表, 都开发lookup逻辑, Hudi只负责做MySQL表的镜像


优点

  1. 真正流批一体 :
    整个链路彻底摆脱离线逻辑
  2. **时效性最高 : **所有更新都能及时反映到下游

缺点

  1. 维护成本大 :
    每张Hudi表都镜像于一张MySQL表, 链路加长, 复杂度提高
  2. 存储冗余 :
    每张表各在MySQL与Hudi存一份, 同时, lookup还需要索引支撑, 磁盘占用高

最终结论

  1. 因为C端业务的特殊性, 需要MySQL提供点查能力, 所以第二种方案的磁盘冗余处于可接受范围
  2. 第一种方案T+1的下限无法被接受, 若提高离线修复的频率, 考虑到Flink已经维护大状态, 或将需要较大的内存开销

所以最终方案选定为第二种 :
**MySQL中间表方案**
, 优化后的整体架构如下

  1. ODS层的Hudi充当一个Queryable Kafka, 提供CDC给下游数据
  2. 实时ETL通过MySQL完成, 对于每一张新的结果表, 都会原样镜像一份到Hudi
  3. Doris与Hive通过读RO表完成与Hudi的统一集成

方案实施

增量实时写入

table.type

根据上述方案, 我们的数据写入是完全镜像于每个flink job的产出MySQL表, 绝大部分表日更新量在50w~300w, 为了保证写入的稳定性, 我们决定采用MOR表

index.type

在选择index的时候, 因为BLOOM随着数据量的上升, 瓶颈出现比较快, 我们的候选方式有FLINK_STATE与BUCKET, 综合考虑以下几点要素

  1. **数据量 : **当数据量超过5e, 社区的推荐方案是使用BUCKET, 目前我们常见的表数据量浮动在2e - 4e
  2. **维护成本 : **使用Flink_STATE作为index时, 程序重启如果没有从检查点恢复, 需要开始bootstrap重新加载索引
  3. **资源占用 : **为保证稳定, FLINK_STATE需要TaskManager划分0.5~1G左右的内存用于运行Rocksdb, 而BUCKET则几乎不需要状态开销
  4. **横向扩展 : **bucket_num一经确认, 则无法更改(高版本的CONSISTENT_HASHING BUCKET依赖Clustering可以实现动态bucket_num), FLINK_STATE无相关概念

我们最终选择使用BUCKET

  1. 它不与RocksDB绑定, 资源占用较低
  2. 不需要bootstrap, 便于维护
  3. 考虑到数据量与横向扩展, 我们预估数据量为5e~10e, 在该场景下, BUCKET会有更好的表现

同时, 综合参考社区推荐与相关最佳实践的文献,

  1. 我们限制每个Parquet文件在2G以内
  2. 假设Parquet+Gzip的压缩比率在5:1
  3. 预估数据量在10e量级的表

最终, 我们设置bucket_num为128

离线写入

为了快速整合到历史已经上线的表, 存量数据的快速导入同样也是必不可少的, 通过官网学习, 我们设计了两种方案

  1. **bulk_insert : **优点是速度快, 没有log小文件, 缺点是不够便捷, 需要学习和引入成本
  2. **大并发的upsert : **优点是只需要加大并行度, 使用最简单, 缺点是产生大量小文件, 写入完毕后第一次compaction非常耗费资源

在分别对上述2种方案进行测试后, 我们决定采用bulk_insert的方式, 最大的因素还是大并发的Upsert在第一次写入后, 需要的compaction资源非常大, 需要在第一次compaction后再次调整运行资源, 不便于自动化

Compaction

  • 同步
    • **优点 : **便于维护
    • **缺点 : **流量比较大的时候, 干扰写流程; 在存量数据大, 增量数据小的情况下, 资源难以分配
  • 异步
    • **优点 : **与同步任务隔离, 不干扰写流程, 可自由配置资源
    • **缺点 : **对于每个表, 都需要单独维护一个定时任务

综合考虑运维难度与资源分配后, 我们决定采用异步调度的方式, 因为我们读的都是RO表, 所以对Compaction频率和单次Compaction时间都有限制, 目前的方案是Compacion Plan由同步任务生成, Checkpoint Interval为1分钟, 触发策略为15次Commits

成果落地

流批一体

整合实时链路与离线链路, 所有产出表均由实时逻辑产出

  • 开发工时由之前普遍的
    离线2PD+实时3PD
    提升至
    实时3PD
    , 效率
    提升40%
  • 每个单元维护成本由
    1名实时组同学+1名离线组同学
    变更为只需要
    1位实时组同学
    , 维护成本
    节约50%

数据整合

配合Doris多源Catalog, 完成数据整合, 打破数据孤岛

  • 使司内Doris集群完成存算分离与读写分离,
    节约磁盘资源30T+
    , 集群故障率由最高
    3次/月
    降至
    1次/月
    , 稳定性提升
    70%
  • 下线高性能(SSD存储)Hbase与GaussDB, 节约成本
    50w/年

平衡计算压力

之前Hive的每日数据由单独离线集群通过凌晨的多路归并完成多版本合并, 目前只需要一个实时集群

  • 退订离线集群70%弹性节点, 节约成本
    30w/年

经验总结

Checkpoint反压优化

在我们测试写入的时候, Checkpoint时间比较长, 而且会有反压产生, 追踪StreamWriteFunction.processElement()方法, 发现数据缓情况如下

为了将flush的压力分摊开, 我们的方案就是减小buffer

ps : 默认write.task.max.size必须大于228M
最终的参数 :

-- index
'index.type' = 'BUCKET',
'hoodie.bucket.index.num.buckets' = '128',
-- write
'write.tasks' = '4',
'write.task.max.size' = '512',
'write.batch.size' = '8',
'write.log_block.size' = '64',

FlinkSQL TIMESTAMP类型兼容性

当表结构中有TIMESTAMP(0)数据类型时, 在使用bulk_insert写入存量数据后, 对接upsert流并进行compaction时, 会报错

Caused by: java.lang.IllegalArgumentException: INT96 is deprecated. As interim enable READ_INT96_AS_FIXED flag to read as byte array.

提交issue
https://github.com/apache/hudi/issues/9804
与社区沟通
最终发现是TIMESTAMP类型, 目前只对TIMESTAMP(3)与TIMESTAMP(6)进行了parquet文件与avro文件的类型标准化
解决方法是暂时使用TIMESTAMP(3)替代TIMESTAMP(0)

Hudi Hive Sync Fail

将Hudi表信息同步到Hive原数据时, 遇到报错, 且无法通过修改pom文件依赖解决

java.lang.NoSuchMethodError: org.apache.parquet.schema.Types$PrimitiveBuilder.as(Lorg/apache/parquet/schema/LogicalTypeAnnotation;)Lorg/apache/parquet/schema/Types$Builder

与社区沟通, 发现了相同的问题
https://github.com/apache/hudi/issues/3042
解决方法是修改源码的
packaging/hudi-flink-bundle/pom.xml
, 加入

<relocation>
  <pattern>org.apache.parquet</pattern>
  <shadedPattern>${flink.bundle.shade.prefix}org.apache.parquet</shadedPattern>
</relocation>

并使用

mvn clean install package -Dflink1.17 -Dscala2.12 -DskipTests -Drat.skip=true -Pflink-bundle-shade-hive3 -T 10

手动install源码, 在程序的pom文件中, 使用自己编译的jar包

Hudi Hive Sync 使用 UTC 时区

当使用FlinkSQL TIMESTAMP(3)数据类型写入Hudi, 并开启Hive Sync的时, 查询Hive中的数据, timestamp类型总是比原值多8小时
原因是Hudi写入数据时, 支持UTC时区, 详情见issue
https://github.com/apache/hudi/issues/9424
目前的解决方法是写入数据时, 使用FlinkSQL的
CONVERT_TZ
函数

insert into dwd
select
id,CAST(CONVERT_TZ(CAST(op_ts AS STRING), 'Asia/Shanghai', 'UTC') AS TIMESTAMP(3)) op_ts
from ods;

HoodieConfig.setDefaults() NPE

在TaskManager初始化阶段, 偶尔遇到NPE, 且调用栈如下

java.lang.NullPointerException: null
at org.apache.hudi.common.config.HoodieConfig.setDefaults(HoodieConfig.java:123)

通过与社区交流, 发现是ReflectionUtils的CLAZZ_CACHE使用HashMap存在线程安全问题
解决方法是引入社区提供的PR :
https://github.com/apache/hudi/pull/9786
通过ConcurrentHashMap解除线程安全问题

未来规划

Metric监控

对接Pushgateway、Prometheus与Grafana, 通过图形化更直截了当的监控Hudi内部相关服务、进程的内存与CPU占用情况, 做到

  1. 优化资源, 提升程序稳定性
  2. 排查潜在不确定因素, 风险预判
  3. 接入告警, 加速问题响应

统一元数据管理

目前是采用封装工具类的方式, 让每个开发同学在产出一张结果表的同时, 在同一个job中启动一条Hudi同步链路, 缺少对Hudi同步任务的统一管理与把控, 后续准备对所有Hudi链路迁出, 进行统一的任务整合与元数据管理

引入CONSISTENT_HASHING BUCKET

后续计划中我们希望在1.0发行版中可以正式将CONSISTENT_HASHING BUCKET投入到线上环境, 现在线上许多3e~5e量级的表都是提前按照10e数据量来预估资源与bucket_num, 有资源浪费的情况, 希望可以通过引入一致性hash的bucket索引, 来解决上述问题

CRYPTO

pr

题目

CRT

from Crypto.Util.number import *
import random

flag=plaintext = 'NSSCTF{****************}'
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
padding_length = 100 - len(plaintext)

for _ in range(padding_length):
    plaintext += random.choice(charset)

public_exponent = 31413537523
message = bytes_to_long(plaintext.encode())
assert message > (1 << 512)
assert message < (1 << 1024)

prime_p = getPrime(512)
prime_q = getPrime(512)
prime_r = getPrime(512)
n1 = prime_p * prime_q
n2 = prime_q * prime_r
ciphertext1 = pow(message, public_exponent, n1)
ciphertext2 = pow(message, public_exponent, n2)
print('c1=', ciphertext1)
print('c2=', ciphertext2)
print('p=', prime_p)
print('r=', prime_r)


'''
c1= 36918910346666666680090654563538246204134840776220077189276689868322808977412566781872132517635399441578464309667998925236488280867210758507758915311644529399878185776345227817559234605958783077866016808605942558810445187434690812992072238407431218047312484354859724174751718700409405142819140636116559320641695
c2= 15601788304485903964195122196382181273808496834343051747331984997977255326224514191280515875796224074672957848566506948553165091090701291545031857563686815297483181025074113978465751897596411324331847008870832527695258040104858667684793196948970048750296571273364559767074262996595282324974180754813257013752
p= 12101696894052331138951718202838643670037274599483776996203693662637821825873973767235442427190607145999472731101517998719984942030184683388441121181962123
r= 10199001137987151966640837133782537428248507382360666666626592866939552984259171772190788036403425837649697437126360866173688083643144865107648483668545682383
'''

我的解答:

这道题也很常见,首先我们来简单分析一下题目吧!

题目给了三个512位的素数p,q,r,并且有:

n1 = p * q

n2 = q * r

密文如下:

c1 = m ** e mod n1

c2 = m ** e mod n2

题目给出了密文c1,c2和p,r,需要我们解出flag。

题目没有给出q,而且n1和n2都有公因子q,因此根据同余性质,我们可以把两个式子分别转到模p和模r下:

c1 = m ** e mod p

c2 = m ** e mod r

根据题目提示crt得到:

c = m ** e mod pr

由于flag只填充到100字节,即800bit左右,满足题目判断条件。因此在模pr下肯定能得出结果。

exp:

#sage
from sympy.ntheory.modular import crt
from Crypto.Util.number import *

c1= 36918910346666666680090654563538246204134840776220077189276689868322808977412566781872132517635399441578464309667998925236488280867210758507758915311644529399878185776345227817559234605958783077866016808605942558810445187434690812992072238407431218047312484354859724174751718700409405142819140636116559320641695
c2= 15601788304485903964195122196382181273808496834343051747331984997977255326224514191280515875796224074672957848566506948553165091090701291545031857563686815297483181025074113978465751897596411324331847008870832527695258040104858667684793196948970048750296571273364559767074262996595282324974180754813257013752
p= 12101696894052331138951718202838643670037274599483776996203693662637821825873973767235442427190607145999472731101517998719984942030184683388441121181962123
r= 10199001137987151966640837133782537428248507382360666666626592866939552984259171772190788036403425837649697437126360866173688083643144865107648483668545682383
e  = 31413537523

n = [p,r]
c = [c1,c2]
M = crt(n,c)[0]

phi = (p-1)*(r-1)
d = inverse(e,phi)
print(long_to_bytes(pow(M,d,p*r)))

#NSSCTF{yUanshEnx1ncHun2o23!}

break

题目

私钥好像坏掉了,如何拿到里面的数据捏~

pri-break.pem:

Bc8tSTrvGJm2oYuCzIz+Yg4nwwKBgQDiYUawe5Y+rPbFhVOMVB8ZByfMa4LjeSDd
Z23jEGvylBHSeyvFCQq3ISUE40k1D2XmmeaZML3a1nUn6ORIWGaG2phcwrWLkR6n
ubVmb1QJSzgzmFHGnL56KHByZxD9q6DPB+o6gGWt8/6ddBl2NIZU/1btdPQgojfA
XXJFzR92RQKBgQC7qlB0U7m2U4FdG9eelSd+WSKNUVllZAuHji7jgh7Ox6La9xN5
miGZ1yvP44yX218OJ9Zi08o6vIrM6Eil45KzTtGm4iuIn8CMpox+5eUtoxyvxa9r
s2Wu+IRZN9zCME+p+qI8/TG27dIyDzsdgNqcUo8ESls7uW5/FEA7bYTCiQKBgQC7
1KybeB+kZ0zlfIdi8tVOpeI+uaHDbdh3+/5wHUsD3hmfg7VAag0q/2RA1vkB/oG1
QVLVHl0Yu0I/1/u5jyeakrtClAegAsvlrK+3i321rGS4YpTPb3SX1P/f3GZ7o7Ds
touA+NHk8IL9T7xkmJYw5h/RLG32ucH6aU6MXfLR5QKBgD/skfdFxGWxhHk6U1mS
27IM9jJNg9xLz5nxzkqPPhLn+rdgIIuTuQtv++eEjEP++7ZV10rg5yKVJd/bxy8H
2IN7aQo7kZWulHTQDZMFwgOhn0u6glJi+qC8bWzYDFOQSFrY9XQ3vwKMspqm+697
xM+dMUW0LML6oUE9ZjEiAY/5
-----END PRIVATE KEY-----

密文:

6081370370545409218106271903400346695565292992689150366474451604281551878507114813906275593034729563149286993189430514737137534129570304832172520820901940874698337733991868650159489601159238582002010625666203730677577976307606665760650563172302688129824842780090723167480409842707790983962415315804311334507726664838464859751689906850572044873633896253285381878416866666605301919877714965930289139926666661644393144686543207867970807469735534838601255712764863973853116693691206791007433101433703535127367245739289103650669095061417223994665200039533840922696282929063608853551346533188464573323230476645532002621795338655

我的解答:

题目泄露了私钥pem文件的尾部(头部缺失)。

参考文章:
你懂RSA吗

把数据按02简单分组,猜测本题flag较短,我们直接在模q下解密。

exp:

from Crypto.Util.number import *

q = 0xe26146b07b963eacf6c585538c541f190727cc6b82e37920dd676de3106bf29411d27b2bc5090ab7212504e349350f65e699e69930bddad67527e8e448586686da985cc2b58b911ea7b9b5666f54094b38339851c69cbe7a2870726710fdaba0cf07ea3a8065adf3fe9d741976348654ff56ed74f420a237c05d7245cd1f7645
dq = 0xbbd4ac9b781fa4674ce57c8762f2d54ea5e23eb9a1c36dd877fbfe701d4b03de199f83b5406a0d2aff6440d6f901fe81b54152d51e5d18bb423fd7fbb98f279a92bb429407a002cbe5acafb78b7db5ac64b86294cf6f7497d4ffdfdc667ba3b0ecb68b80f8d1e4f082fd4fbc64989630e61fd12c6df6b9c1fa694e8c5df2d1e5
c = 6081370370545409218106271903400346695565292992689150366474451604281551878507114813906275593034729563149286993189430514737137534129570304832172520820901940874698337733991868650159489601159238582002010625666203730677577976307606665760650563172302688129824842780090723167480409842707790983962415315804311334507726664838464859751689906850572044873633896253285381878416866666605301919877714965930289139926666661644393144686543207867970807469735534838601255712764863973853116693691206791007433101433703535127367245739289103650669095061417223994665200039533840922696282929063608853551346533188464573323230476645532002621795338655

m = pow(c,dq,q)
print(long_to_bytes(m))
# flag{oi!_you_find___what_i_Wa1t_talK_y0n!!!}

MISC

Litter(一、二、三)

题目信息

公司的服务器被人入侵了,并且公司的一些敏感信息被攻击者所盗取。现在你作为公司的 SOC 分析师,运维部门为你提取出来了当时时间段内服务器的流量数据,请对流量数据进行分析研判,在其中抽丝剥茧。

1. 请找到攻击者所使用到的隧道工具的文件名称(如 Supertools.exe ),请问文件名称的md5 lowercase的值是什么?

2. 攻击者试图将攻击过程中所使用的隧道工具重命名进行隐藏,请问重命名后的文件名是什么?

3. 攻击者在服务器上窃取了一份客户数据文件,请问在这份文件中,第418条记录所记录的客户的电子邮箱地址为?

我的解答:

题目给了一个流量包,流量分析可以清楚地看到有很多向microsoft365.com发送的dns请求。

我们使用tshark提取所有请求,然后放到cyberchef里分析即可

"D:\Wireshark\tshark.exe" -r suspicious_traffic.pcap -T fields -e dns.qry.name > data.txt

问题一、二:前两问都可以直接找到。答案如下:

dnscat2-v0.07-client-win32.exe
win_install.exe

问题三:直接搜索
@符号定位邮箱的位置,然后发现格式是csv,根据第一个行号搜索418找到位置即可。

bneal@gmail.com

原文链接:
https://gaoyubo.cn/blogs/89d6d9be.html

一、前端编译与优化

Java技术下讨论“编译期”需要结合具体上下文语境,因为它可能存在很多种情况:

  • 前端编译器(叫“编译器的前端”更准确一些)把
    .java文件
    转变成
    .class文件
    的过程

    JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)

  • 即时编译器(常称JIT编译器,Just In Time Compiler)运行期
    把字节码转变成本地机器码
    的过程

    HotSpot虚拟机的C1、C2编译器,Graal编译器

  • 提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接
    把程序编译成与目标机器指令集相关的二进制代码
    的过程

    JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET 。

本章标题中的“前端”指的是由
前端编译器
完成的编译行为,对于前端编译优化,有以下说法:

  1. 前端编译器对代码的运行效率几乎没有任何优化措施可言

  2. Java虚拟机设计团队选择把对
    性能的优化全部集中到运行期的即时编译器

    这样可以让那些不是由Javac产生的Class文件也同样能享受到编译器优化措施所带来的性能红利

  3. 相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。

  4. Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;

  5. 前端编译器在编译期的优化过程,支撑着程序员的编码效率和语言使用者的幸福感的提高

1.1Javac编译器

从Javac源代码的总体结构来看,编译过程大致可以分为
1个准备过程和3个处理过程
,它们分别如下所示:

  1. 准备过程:初始化插入式注解处理器

  2. 解析与填充符号表过程,包括:

    ​ 词法、语法分析:将源代码的字符流转变为标记集合,构造出抽象语法树

    ​ 填充符号表:产生符号地址和符号信息

  3. 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段

  4. 分析与字节码生成过程,包括:

    标注检查:对语法的静态信息进行检查。

    数据流及控制流分析:对程序动态运行过程进行检查。

    解语法糖:将简化代码编写的语法糖还原为原有的形式。

    字节码生成:将前面各个步骤所生成的信息转化成字节码。

  5. 对于以上过程:执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转 回到之前的解析、填充符号表的过程中重新处理这些新符号

  6. 整个编译过程主要的处理由图中标注的8个方法来完成

解析和填充符号表

词法语法分析

1.词法分析
:词法分析是将源代码的字符流转变为标记(Token)集合的过程。

2.语法分析
:语法分析是根据标记序列构造抽象语法树的过程

  • 抽象语法树:抽象语法树(Abstract Syntax Tree,AST)是一 种用来
    描述程序代码语法结构的树形表示方式
    ,抽象语法树的每一个节点都代表着程序代码中的一个语法结构

  • 包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

  • 抽象语法树可通过Eclipse AST View插件查看,抽象语法树是以com.sun.tools.javac.tree.JCTree 类表示的

  • 经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,
    后续的操作都建立在抽象语法树之上

填充符号表

符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构(可以理解成哈希表中的键值对的存储形式)

符号表中所登记的信息在编译的不同阶段都要被用到:

  • 语义分析的过程中,符号表所登记的内容将用于语义检查 (如检查一个名字的使用和原先的声明是否一致)和产生中间代码
  • 目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

注解处理器

可以把
插入式注解处理器
看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。

譬如Java著名的编码效率工具Lombok,它可以通过注解来实现自动产生 getter/setter方法、进行空置检查、生成受查异常表、产生equals()和hashCode()方法,等等.

语义分析与字节码生成

语义分析的主要任务则是对结构上正确的源 程序进行上下文相关性质的检查,譬如进行
类型检查、控制流检查、数据流检查
,等等

int a = 1;
boolean b = false;
char c = 2;

//后续可能出现的赋值运算:

int d = a + c; 
int d = b + c; //错误,
char d = a + c; //错误

//C语言中,a、b、c的上下文定义不变,第二、三种写法都是可以被正确编译的

我们编码时经常能在IDE 中看到由红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。

1.标注检查

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,等等,刚才3个变量定义的例子就属于标注检查的处理范畴

在标注检查中,还会顺便进行 一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施 之一(代码优化几乎都在即时编译器中进行)。

int a = 2 + 1;

由于编译期间进行了常量折叠,所以在代码里面定 义“a=1+2”比起直接定义“a=3”来,并不会增加程序运行期哪怕仅仅一个处理器时钟周期的处理工作量。

2.数据及控制流分析

可以检查出诸如程序局部变量
在使用前是否有赋值
、方法的
每条路径是否都有返回值
、是否所有的受查异常都被正确处理了等问题。

3.解语法糖

在Javac的源码中,解语法糖的过程由desugar()方法触发。

Java中最常见的语法糖包括了前面提到过的泛型、变长参数、自动装箱拆箱,等等。

4.字节码生成

字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.sun.tools.javac.jvm.Gen类来完成。

字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。

实例构造器()方法和类构造器()方法就是在这个阶段被添加到语 法树之中的

字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK 5)的append()操 作,等等。

1.2语法糖的本质

泛型

泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数。

Java选择的泛型实现方式叫作
类型擦除式泛型
:Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的
裸类型
了,并且在相应的地方插入了强制转型代码。

类型擦除

裸类型”
(Raw Type)的概念:裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type)

ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // 裸类型
list = ilist;
list = slist;

如何实现裸类型?

直接在编译时把ArrayList
通过类型擦除还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令

泛型擦除前的例子

public static void main(String[] args) {
    Map<String, String> map = new HashMap<String, String>();
    map.put("hello", "你好");
    map.put("how are you?", "吃了没?");
    System.out.println(map.get("hello"));
    System.out.println(map.get("how are you?"));
}

把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了

public static void main(String[] args) {
    Map map = new HashMap();//裸类型
    map.put("hello", "你好");
    map.put("how are you?", "吃了没?");
    System.out.println((String) map.get("hello"));//强制类型转换
    System.out.println((String) map.get("how are you?"));
}

当泛型遇到重载

public class GenericTypes {
    public static void method(List<String> list) {
    	System.out.println("invoke method(List<String> list)");
    }
    public static void method(List<Integer> list) {
    	System.out.println("invoke method(List<Integer> list)");
    }
}

参数列表在
特征签名
中,因此参数列表不同时,可以进行重载,但是由于所有泛型都需要通过类型擦出转化为裸类型,导致参数都是
List list
,所以不能重载。会报错。

自动装箱、拆箱与遍历循环

public static void main(String[] args) {
    List<Integer> list = Arrays.asList(1, 2, 3, 4);
    int sum = 0;
    for (int i : list) {
        sum += i;
    }
    System.out.println(sum);
}

编译后:

public static void main(String[] args) {
    List list = Arrays.asList( new Integer[] {
    Integer.valueOf(1),
    Integer.valueOf(2),
    Integer.valueOf(3),
    Integer.valueOf(4) });
    int sum = 0;
    for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
        int i = ((Integer)localIterator.next()).intValue();
        sum += i;
    }
    System.out.println(sum);
}

二、后端编译与优化

如果把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话, 那编译器无论在何时、在何种状态下把
Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码
,都可以视为整个编译过程的后端

2.1即时编译器

由于
即时编译器编译本地代码需要占用程序运行时间
,通常要编译出优化程度越高的代码,所花 费的时间便会越长;
而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。
为了在程序启动响应速度与运行效率之间达到最佳平衡:

HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包 括:

  • 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启 性能监控功能。
  • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

编译对象与触发条件

会被即时编译器编译的目标是
热点代码
,这里所指的热点代码主要有两类:


  • 多次
    调用的方法。

  • 多次
    执行的循环体。

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。

这种编译方式因为 编译发生在方法执行的过程中,因此被很形象地称为
栈上替换
(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

多少次才算“多次”呢?

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection),判定方式:

  1. 基于采样的热点探测(Sample Based Hot Spot Code Detection)

    会周期性地检查各个线程的调用栈顶,如果发现
    某个方法经常出现在栈顶,那这个方法就是
    热点方法

    缺点:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而 扰乱热点探测

  2. 基于计数器的热点探测(Counter Based Hot Spot Code Detection)

    为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果
    执行次数超过一定的阈值就认为它是
    热点方法

    缺点:实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能 直接获取到方法的调用关系

J9用过第一种采样热点探测,而在HotSpot 虚拟机中使用的是第二种基于计数器的热点探测方法,

HotSpot 中每个方法的 2 个计数器

  • 方法调用计数器
    • 统计方法被调用的次数,处理多次调用的方法的。
    • 默认统计的不是方法调用的绝对次数,而是方法在一段时间内被调用的次数,如果超过这个时间限制还没有达到判为热点代码的阈值,则该方法的调用计数器值减半。
      • 关闭热度衰减:
        -XX: -UseCounterDecay
        (此时方法计数器统计的是方法被调用的绝对次数);
      • 设置半衰期时间:
        -XX: CounterHalfLifeTime
        (单位是秒);
      • 热度衰减过程是在 GC 时顺便进行。
      • 默认阈值在客户端模式下是1500次,在服务端模式下是10000次,
  • 回边计数器
    • 统计一个方法中 “回边” 的次数,处理多次执行的循环体的。
      • 回边:在字节码中遇到控制流向后跳转的指令(不是所有循环体都是回边,空循环体是自己跳向自己,没有向后跳,不算回边)。
    • 调整回边计数器阈值:
      -XX: OnStackReplacePercentage
      (OSR比率)
      • Client 模式:
        回边计数器的阈值 = 方法调用计数器阈值 * OSR比率 / 100
      • Server 模式:
        回边计数器的阈值 = 方法调用计数器阈值 * ( OSR比率 - 解释器监控比率 ) / 100

编译过程

虚拟机在代码编译未完成时会按照解释方式继续执行,
编译动作在后台的编译线程执行。

禁止后台编译:
-XX: -BackgroundCompilation
,打开后这个开关参数后,交编译请求的线程会等待编译完成,然后执行编译器输出的本地代码。

在后台编译过程中,客户端编译器与服务端编译器是有区别的。

客户端编译器

是一个相对简单快速的三段式编译器,主要的
关注点在于局部性的优化
,而放弃了许多耗时较长的全局优化手段。

  1. 第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配 (Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,
    如方法内联、 常量传播等优化将会在字节码被构造成HIR之前完成。

  2. 第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

  3. 最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。客户端编译器大致的执行过程如图


服务端编译器

是专门面向服务端的典型应用场景,执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开 (Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。

另外,还可能根据解释器或客户端编译器提供的 性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测 (Branch Frequency Prediction)等

服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如 RISC)上的大寄存器集合。

2.2提前编译器

现在提前编译产品和对其的研究有着两条明显的分支:

  1. 与传统C、C++编译器类似的,在
    程序运行之前把程序代码编译成机器码的静态翻译工作

  2. 把原本
    即时编译器在运行时要做的编译工作提前做好并保存下来
    ,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用。(本质是给即时编译器做缓存加速,去改善Java程序的启动时间)


    在目前的Java技术体系里,这种提前编译已经完全被主流的商用JDK支持

    困难:这种提前编译方式不仅要和目标机器相关,甚至还必须与HotSpot虚拟机的运行时参数绑定(如生成内存屏障代码)才能正确工作,要做提前编译的话,自然也要把这些配合的工作平移过去。

2.3即时编译器的优势

提前编译的代码输出质量,一定会比即时编译更高吗?

以下为即时编译器相较于提前编译器的优势:

  1. 性能分析制导优化(Profile-Guided Optimization,PGO)

    抽象类通常会是什么实际类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环通常会进行多少次等
    ,这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解, 最多只能依照一些启发性的条件去进行猜测。但
    在动态运行时却能看出它们具有非常明显的偏好性。
    就可以把热的代码集中放到 一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。

  2. 激进预测性优化(Aggressive Speculative Optimization)

    静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果) 与优化前是等效的

    然而,即时编译的策略就可以不必这样保守,
    可以大胆地按照高概率的假设进行优化
    ,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。


    如果Java虚拟机真的遇到虚方法就去查虚表而不做内 联的话,Java技术可能就已经因性能问题而被淘汰很多年了。

    实际上虚拟机会通过类继承关系分析等 一系列激进的猜测去做去虚拟化(Devitalization),以保证绝大部分有内联价值的虚方法都可以顺利内联。

  3. 链接时优化(Link-Time Optimization,LTO)

    如C、C++的程序要调用某个动态链接库的某个方法,就会出现很明显的边界隔阂,还难以优化。
    这是因为主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码。

    然而,
    Java语言天生就是动态链接的
    ,一个个 Class文件在运行期被加载到虚拟机内存当中。

三、编译器优化技术

类型 优化技术
编译器策略 (Compiler Tactics)
延迟编译 (Delayed Compilation)
分层编译 (Tiered Compilation)
栈上替换 (On-Stack Replacement)
延迟优化 (Delayed Reoptimization)
静态单赋值表示 (Static Single Assignment Representation)
基于性能监控的优化技术 (Profile-Based Techniques)
乐观空值断言 (Optimistic Nullness Assertions)
乐观类型断言 (Optimistic Type Assertions)
乐观类型增强 (Optimistic Type Strengthening)
乐观数组长度增强 (Optimistic Array Length Strengthening)
裁剪未被选择的分支 (Untaken Branch Pruning)
乐观的多态内联 (Optimistic N-morphic Inlining)
分支频率预测 (Branch Frequency Prediction)
调用频率预测 (Call Frequency Prediction)
基于证据的优化技术 (Proof-Based Techniques)
精确类型推断 (Exact Type Inference)
内存值推断 (Memory Value Inference)
内存值跟踪 (Memory Value Tracking)
常量折叠 (Constant Folding)
重组 (Reassociation)
操作符退化 (Operator Strength Reduction)
空值检查消除 (Null Check Elimination)
类型检测退化 (Type Test Strength Reduction)
类型检测消除 (Type Test Elimination)
代数简化 (Algebraic Simplification)
公共子表达式消除 (Common Subexpression Elimination)
数据流敏感重写 (Flow-Sensitive Rewrites)
条件常量传播 (Conditional Constant Propagation)
基于流承载的类型缩减转换 (Flow-Carried Type Narrowing)
无用代码消除 (Dead Code Elimination)
语言相关的优化技术 (Language-Specific Techniques)
类型继承关系分析 (Class Hierarchy Analysis)
去虚拟化 (Devirtualization)
符号常量传播 (Symbolic Constant Propagation)
自动装箱消除 (Autobox Elimination)
逃逸分析 (Escape Analysis)
锁消除 (Lock Elision)
锁膨胀 (Lock Coarsening)
消除反射 (De-reflection)
内存及代码位置变换 (Memory and Placement Transformation)
表达式提升 (Expression Hoisting)
表达式下沉 (Expression Sinking)
冗余存储消除 (Redundant Store Elimination)
相邻存储合并 (Adjacent Store Fusion)
交汇点分离 (Merge-Point Splitting)
循环变换 (Loop Transformations)
循环展开 (Loop Unrolling)
循环剥离 (Loop Peeling)
安全点消除 (Safepoint Elimination)
迭代范围分离 (Iteration Range Splitting)
范围检查消除 (Range Check Elimination)
循环向量化 (Loop Vectorization)
全局代码调整 (Global Code Shaping)
内联 (Inlining)
全局代码外提 (Global Code Motion)
基于热度的代码布局 (Heat-Based Code Layout)
Switch调整 (Switch Balancing)
控制流图变换 (Control Flow Graph Transformation)
本地代码编排 (Local Code Scheduling)
本地代码封包 (Local Code Bundling)
延迟槽填充 (Delay Slot Filling)
着色图寄存器分配 (Graph-Coloring Register Allocation)
线性扫描寄存器分配 (Linear Scan Register Allocation)
复写聚合 (Copy Coalescing)
常量分裂 (Constant Splitting)
复写移除 (Copy Removal)
地址模式匹配 (Address Mode Matching)
指令窥空优化 (Instruction Peepholing)
基于确定有限状态机的代码生成 (DFA-Based Code Generator)

3.1一个优化的例子

原始代码:

static class B {
    int value;
    final int get() {
        return value;
    }
}

public void foo() {
    y = b.get();
    // ...do stuff...
    z = b.get();
    sum = y + z;
}

第一步优化:
方法内联(一般放在优化序列最前端,因为对其他优化有帮助)

目的:

  • 去除方法调用的成本(如建立栈帧等)
  • 为其他优化建立良好的基础
public void foo() {
    y = b.value;
    // ...do stuff...
    z = b.value;
    sum = y + z;
}

第二步优化:
公共子表达式消除

public void foo() {
    y = b.value;
    // ...do stuff...  // 因为这部分并没有改变 b.value 的值
                       // 如果把 b.value 看成一个表达式,就是公共表达式消除
    z = y;             // 把这一步的 b.value 替换成 y
    sum = y + z;
}

第三步优化:
复写传播

public void foo() {
    y = b.value;
    // ...do stuff...
    y = y;             // z 变量与以相同,完全没有必要使用一个新的额外变量
                       // 所以将 z 替换为 y
    sum = y + z;
}

第四步优化:
无用代码消除

无用代码:

  • 永远不会执行的代码
  • 完全没有意义的代码
public void foo() {
    y = b.value;
    // ...do stuff...
    // y = y; 这句没有意义的,去除
    sum = y + y;
}

3.2方法内联

它是
编译器最重要的优化手段
,甚至都可以不加 上“之一”。

除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础

目的是:去除方法调用的成本(如建立栈帧等),并为其他优化建立良好的基础,所以一般将方法内联放在优化序列最前端,因为它对其他优化有帮助。

为了解决虚方法的内联问题:引入
类型继承关系分析(Class Hierarchy Analysis,CHA)

用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等。

  • 对于非虚方法:
    • 直接进行内联,其调用方法的版本在编译时已经确定,是根据变量的静态类型决定的。
  • 对于虚方法:
    (激进优化,要预留“逃生门”)
    • 向 CHA 查询此方法在当前程序下是否有多个目标可选择;
      • 只有一个目标版本:
        • 先对这唯一的目标进行内联;
        • 如果之后的执行中,虚拟机没有加载到会令这个方法接收者的继承关系发生改变的新类,则该内联代码可以一直使用;
        • 如果加载到导致继承关系发生变化的新类,就抛弃已编译的代码,退回到解释状态进行执行,或者重新进行编译。
      • 有多个目标版本:
        • 使用内联缓存,未发生方法调用前,内联缓存为空;
        • 第一次调用发生后,记录调用方法的对象的版本信息;
        • 之后的每次调用都要先与内联缓存中的对象版本信息进行比较;
          • 版本信息一样,继续使用内联代码,是一种
            单态内联缓存
            (Monomorphic Inline Cache)
          • 版本信息不一样,说明程序使用了虚方法的多态特性,退化成
            超多态内联缓存
            (Megamorphic Inline Cache),查找虚方法进行方法分派。

3.3逃逸分析【最前沿】

基本行为

分析对象的作用域,看它有没有能在当前作用域之外使用:

  • 方法逃逸:对象在方法中定义之后,能被外部方法引用,如作为参数传递到了其他方法中。
  • 线程逃逸:赋值给 static 变量,或可以在其他线程中访问的实例变量。

对于不会逃逸到方法或线程外的对象能进行优化

  • 栈上分配:
    对于不会逃逸到方法外的对象,可以在栈上分配内存,这样这个对象所占用的空间可以随栈帧出栈而销毁,减小 GC 的压力。
  • 标量替换(重要):
    • 标量:基本数据类型和 reference。
    • 不创建对象,而是将对象拆分成一个一个标量,然后直接在栈上分配,是栈上分配的一种实现方式。
    • HotSpot 使用的是标量替换而不是栈上分配,因为实现栈上分配需要更改大量假设了 “对象只能在堆中分配” 的代码。
  • 同步消除
    • 如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,对这个变量实施的同步措施也就可以安全地消除掉。

虚拟机参数

  • 开启逃逸分析:
    -XX: +DoEscapeAnalysis
  • 开启标量替换:
    -XX: +EliminateAnalysis
  • 开启锁消除:
    -XX: +EliminateLocks
  • 查看分析结果:
    -XX: PrintEscapeAnalysis
  • 查看标量替换情况:
    -XX: PrintEliminateAllocations

例子

Point类的代码,这就是一个包含x和y坐标的POJO类型

// 完全未优化的代码
public int test(int x) {
    int xx = x + 2;
    Point p = new Point(xx, 42);
    return p.getX();
}

步骤1:构造函数内联

public int test(int x) {
    int xx = x + 2;
    Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
    p.x = xx; // Point构造函数被内联后的样子
    p.y = 42;
    return p.x; // Point::getX()被内联后的样子
}

步骤2:标量替换

经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸, 这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从 而避免Point对象实例被实际创建

public int test(int x) {
    int xx = x + 2;
    int px = xx;
    int py = 42;
    return px;
}

步骤3:无效代码消除

通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,

public int test(int x) {
    return x + 2;
}

系列文章目录和关于我

0丶引入

笔者社招一年半经验跳槽加入阿里约1年时间,无意间发现一些阿里语雀上的一些面试题题库,出于学习目的在此进行记录。

  • 这一篇主要写一些有趣的笔试题(非leetcode),这些有的考验并发编程,有的考验设计能力。
  • 笔者不是什么技术大牛,此处笔试题充满主观思考,并不一定是满分答案,欢迎评论区一起探讨。
  • 不止八股:面试题之外,笔者会更多的思考下底层原理,不只是简单的背诵。

下面这个题目也是笔者面试阿里笔试做过的一道笔试题,现在回想自己那时候写的也是一坨

1.题目-限流组件设计

网站或者API服务有可能被恶意访问导致不可用,为了防止流量过大,通常会有限流设计。
请实现一个 RateLimiter 类,包含 isAllow 方法。每个请求包含一个 resource 资源,如果resource 在 1 秒钟内有超过 N 次请求,就拒绝响应。

public interface IRateLimiter{
		boolean isAllow(String resource);
}

2.笔者的题解

笔者在面试的时候,其实没看过,没使用过sentinel(
《Sentinel基本使用与源码分析》
),也没看过Guava的RateLimiter,笔者上一份工作是一个银行内部的工具,用户就是100-的银行经历,限流是不可能限流。

因此第一反应是使用一个变量记录当前是第几秒,另外一个变量记录当前当前通过了多少请求,这种算法也叫
计数器算法
。所以这里引入了两个问题:

  • 需要根据resource映射到一个对象,对象具备两个字段——记录第几秒和通过请求数
  • 如何保证 【两个字段——记录第几秒和通过请求数】更新的线程安全

2.1 使用锁实现计数器算法

2.1.1 回家等通知的写法

image-20240113202430436

如上,我们抽象出CountFlowChecker负责这一个资源的限流控制,checkerMap中key是资源名称,value是CountFlowChecker。然后使用synchronized修饰checkerMap来实现checkerMap初始化的线程安全。这一段代码有哪些问题?

  1. checkerMap读是没有竞争的,不需要加锁
  2. 锁的粒度太大了——锁定整个checkerMap,让所有调用CountRateLimiter1#isAllow都是串行的!

2.1.2 解决
[checkerMap读是没有竞争的,不需要加锁]
的问题

image-20240113203117383

如上读不加锁,只有发现没有初始化,需要写的是才进入绿色部分代码进行初始化,但是绿色部分部分存在bug

image-20240113203341149

如图红色,蓝色代表两个并发的请求,二者访问的时候CountFlowChecker都没有初始化,so都来到绿色部分,假设红色请求先拿到锁并成功初始化了CountFlowChecker然后释放了锁,这时候蓝色请求处理线程被唤醒,将覆盖红色请求处理线程写入的CountFlowChecker。如何解决昵?

image-20240113203658864

其实和单例模式中的双重if异曲同工之妙,这里获取到锁后再次读取,只有为null才进行初始化,解决了上面红蓝线程覆盖的情况!这种写法在Spring的Bean初始化中也有使用。

但是即使你这样写了,也是要回家等通知的!因为
锁的粒度太大了——锁定整个checkerMap,让所有调用CountRateLimiter1#isAllow都是串行的!

2.1.3 减小锁粒度

image-20240113204031280

如上图,锁整个checkerMap相当于把checkerMap把checkerMap中每一个数组都锁了,但是不同的数组槽之间是没有线程安全问题的,比如数组下标0对应了资源A,数组下标2对应了资源B,A和B是可以并行做数据变更操作的!

这其实就对应了ConcurrentHashMap中减小锁粒度思想!因此可以这样优化:

image-20240113204534352

这里我们使用了
ConcurrentHashMap#computeIfAbsent
,保证了假设resouce对应数组槽没有元素的时候,会串行的将new 出来的CountFlowChecker塞到checkerMap中。ConcurrentHashMap中是对每一个数组槽使用cas+synchronized进行初始化,原理可见:
《JUC源码学习笔记8——ConcurrentHashMap源码分析》

至此我们解决了resouce和CountFlowChecker的关联,接下来就是CountFlowChecker#isAllow的实现了,也是限流算法真正核心的部分!

下面是使用synchronized实现的方式:

@Data
static class CountFlowChecker {
    // 当前是第几秒
    private long seconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
    // 当前seconds这一秒请求了多少次
    private int count = 0;
    // qps = 10
    private int max = 10;

    public synchronized boolean isAllow(int acquire) {
        if (acquire > max) {
            return false;
        }
        long current = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
        if (current == seconds) {
            boolean flag = count + acquire <= max;
            count += acquire;
            return flag;
        }
        count = acquire;
        return true;
    }
}

如上我们使用synchronized保证seconds和count更新的原子性和可见性。

虽然synchronized具备轻量级这种乐观锁的优化策略,但是在并发比较高的情况下会升级为重量锁,最后会导致更多的系统调用和线程上下文切换,所以这时候通常需要考虑使用cas进行优化。(guava的RateLimiter就是使用的synchronized,但是面试官大概率会想看你对cas的理解如何)

2.2 使用cas实现计数器算法

推荐阅读:《JUC源码学习笔记4——原子类源码分析,CAS,Volatile内存屏障,缓存伪共享与UnSafe相关方法》

这里主要是怎么用cas完成seconds和count两个变量的更新

  • AtomicReference

    我们自定义一个类,里面包含当前是第一秒和这一秒通过了多次请求

  • AtomicStampedReference

    本质是解决cas中的ABA问题,但是在这里我们可以使用
    stamp 表示当前是第几秒

如下是使用AtomicStampedReference的实现方式

public class CountFlowChecker1 {

    /**
     * Integer 表示这一秒内通过的请求,
     * stamp 表示当前是第几秒
     */
    private AtomicStampedReference<Integer> flowCountHelper;

    private int max;

    public CountFlowChecker1(int max) {
        this.max = max;
        flowCountHelper = new AtomicStampedReference<>(0, currentSeconds());
    }


    public boolean isAllow(int acquire) {
        if (acquire > max) {
            return false;
        }
        while (true) {
            // 当前是第几秒
            int currentSeconds = currentSeconds();
            // 上一次统计是第几秒
            int preSeconds = flowCountHelper.getStamp();
            // 上一次的数量
            Integer preCount = flowCountHelper.getReference();

            // 是同一秒,超过了阈值,那么false
            if (preSeconds == currentSeconds & preCount + acquire > max) {
                return false;
            }
            // 不是同一秒,或者是同一秒没超过阈值,那么cas更新
            if (flowCountHelper.compareAndSet(preCount, preCount + acquire, preSeconds, currentSeconds)) {
                return true;
            }
            // 更新失败 继续自旋
        }
    }

    private static int currentSeconds() {
        return (int) (TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) % Integer.MAX_VALUE);
    }
}

主要是isAllow方法,如上我们可以看到核心思想是在一个自旋中使用cas保证第几秒和请求数量的更新原子性。

但是这里引入一个问题:为什么AtomicStampedReference#compareAndSet可以保证可见性?线程A cas成功,那么线程B cas会失败继续自旋,重新获取
flowCountHelper.getStamp()

flowCountHelper.getReference()
,为什么
getStamp,getReference
可以保证线程B立马可见?

image-20240113225236472

原因就在AtomicStampedReference使用Pair保证stamp和reference,Pair是使用volatile修饰的,对于volatile变量的写操作,会在其后插入一个内存屏障。在Java中,volatile变量的写操作后通常会插入一个"store-store"屏障(Store Barrier),以及一个"store-load"屏障。这些内存屏障确保了以下几点:

  • Store-Store Barrier:这个屏障防止volatile写与之前普通写的指令重排序。也就是说,对volatile变量的写操作之前的所有普通写操作都必须在volatile写之前完成,确保了volatile写之前的修改对于其他线程是可见的。
  • Store-Load Barrier:这个屏障防止volatile写与之后的读写操作重排序。它确保了volatile变量写之后的读取操作不会在volatile写之前发生。这保证了volatile写操作的结果对后续操作是可见的。

ok,我们继续回到这种写法,由于其使用了cas保证原子性,如果一瞬间有1000线程过来,那么1个线程成功,那么999个线程就要继续自旋,导致浪费了很多cpu资源,有没有办法优化一下昵?

2.2.1 使用Thread#yield降低cpu资源浪费

既然太多线程自旋了,那么可以在自旋失败后使用Thread#yield降低这种cpu资源的竞争

image-20240113230146958

但是这种方法也不是非常的优秀,因为它会导致请求处理的rt变高,但是是一种优化思路,咱没办法做到既要有要。

2.2.2 借鉴LongAdder的思想,减少热点数据竞争

如上面的写法,所有线程都在cas竞争修改flowCountHelper中记录数量,这个数量是一个热点数据,我们可以学习LongAdder的做法进行优化

LongAdder 内部有base用于在没有竞争的情况下,进行CAS更新,其中还有Cell数组在冲突的时候根据线程唯一标识对Cell数组长度进行取模,让线程去更新Cell数组中的内容。这样最后的值就是 base+Cell数组之和,LongAdder自然只能保证最终一致性,如果边更新边获取总和不能保证总和正确

如下是借鉴后写的代码

2.2.2.1 基本属性

image-20240113233145759

可以看到我们改变了使用一个Integer记录这一秒请求总数的方式,转而使用一个AtomicIntegerArray数组记录,数组之和才是这一秒通过的总数。

而且还使用了ThreadLocal记录当前线程分配到的位置,一个线程对应AtomicIntegerArray数组中的一个位置,从而实现热点数据分离!!!

但是这也带来了一些弊端,后面代码会有所体现。

2.2.2.2 限流逻辑
public boolean isAllow(int acquire) {
    if (acquire > max) {
        return false;
    }
    while (true) {
        // 当前是第几秒
        int currentSeconds = currentSeconds();
        // 上一次统计是第几秒
        int preSeconds = flowCountHelper.getStamp();
        int currentThreadRandomIndex = THREA_RAMDON_INDEX.get();
        // 不是同一秒 尝试new 一个全新的数组!
        if (currentSeconds != preSeconds) {
            AtomicIntegerArray newCountArray = new AtomicIntegerArray(100);
            newCountArray.set(currentThreadRandomIndex, acquire);
            if (flowCountHelper.compareAndSet(flowCountHelper.getReference(), newCountArray, preSeconds, currentSeconds)) {
                return true;
            }
        }
        // 是同一秒 or cas 失败 如果是cas失败,那么说明存在另外一个线程new了一个权限数组


        // 统计这一秒有多少请求量
        // 细节1 重新使用flowCountHelper.getReference(),因为如果是上面cas失败,那么这里的flowCountHelper.getReference()对应的AtomicIntegerArray被替换成新的了
        AtomicIntegerArray countArray = flowCountHelper.getReference();
        int countArrayLength = countArray.length();
        // 统计总数
        long preCount = 0;
        for (int i = 0; i < countArrayLength; i++) {
            preCount = countArray.get(i);
        }

        // 理论上 上面的for不会消耗太多时间
        // 不够需要的,那么false
        if (preCount + acquire > max) {
            return false;
        }

        // 在currentThreadRandomIndex的原值
        int sourceValue = countArray.get(currentThreadRandomIndex);
         // 细节2:使用的是【细节1】拿到的array 这时候不能重新flowCountHelper.getReference(),因为如果上面的for统计超过了一秒,那么这一次的请求会加到下一秒
        if (countArray.compareAndSet(currentThreadRandomIndex, sourceValue, sourceValue + acquire)) {
            
            // 弊端,这里true 不一定成功的限制了qps,因为上面的求和 与 这里的cas 不具备一致性,存在其他线程修改了的情况
            return true;
        }
        // 理论冲突的概率降低了,不需要 yield 吧
    }
}

可以看到大体思路差不多,其中有两处细节,大家可以品一品

  • 细节1:

    image-20240113234259485

  • 细节2:

    image-20240113234358231

    这里使用的是统计前获取AtomicIntegerArray,为什么不
    flowCountHelper.getReference()
    ?因为存在另外线程发现不是同一秒然后更新了flowCountHelper中AtomicIntegerArray引用的指向,如果重新
    flowCountHelper.getReference()
    可能让上一秒的请求加到下一秒,当然这也不是不可以,这也相当于上一秒借用了下一秒


    • 弊端:求和和cas不具备一致性

      image-20240113234901048

问题:为什么AtomicIntegerArray可以保证数组元素的可见性?

image-20240114002022402

同样是因为使用了内存屏障!

另外笔者这里的AtomicIntegerArray是没法扩容的,默认100个。LongAdder的设计则更为巧妙,LongAdder中存在一个
volatile long base
值,LongAdder会优先case更新base,如果存在多线程导致case失败,才使用数组进行规避,而且还具备扩容的能力,感兴趣的话可以看看笔者写的:
JUC源码学习笔记4——原子类源码分析,CAS,Volatile内存屏障,缓存伪共享与UnSafe相关方法 - Cuzzz - 博客园 (cnblogs.com)

2.3 计数器算法的不足

临界值问题:假设我们qps最大为10,如果在第一秒的前900ms没有请求,但是后100ms通过了10个请求,然后来到下一秒,下一秒的100ms也通过了100ms,那么在第一秒后100ms和下一秒的前100ms一共通过了20个请求,这一段时间内是超出了qps 10的!

为此有了下面的滑动窗口算法

2.4 滑动窗口算法

为了避免计数器中的临界问题,让
限制更加平滑
,将固定窗口中分割出多个小时间窗口,分别在每个小的时间窗口中记录访问次数,然后根据时间将窗口往前滑动并删除过期的小时间窗口。

计数器算法,可以看做只有两个窗口,因此在两个窗口边界的时候会出现临界问题。而滑动窗口统计当前时间处于的1s内产生了多少次请求,避免了临界问题

  • 优点:实现相对简单,且没有计数器算法的临界问题
  • 缺点:无法应对短时间高并发(突刺现象),比如我在间歇性高流量请求,每一批次的请求,处于不同的窗口(图中的虚线窗口)

image-20230216223141292

接下来我们将手写滑动窗口算法(sentinel也是使用的滑动窗口算法:
Sentinel基本使用与源码分析


import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReferenceArray;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * 假设 把一秒分割为 5 份
 * |-----|-----|-----|-----|------|
 * 0   200ms 400ms 600ms 800ms  1000ms
 * 假设当前是10s余500ms 那么应该落到 400ms——600ms之间
 * 算法就是 10s余500ms%1s = 500ms,500ms/(1s/5) = 2 ==> 对应400ms——600ms
 * 这里需要注意 如果不是同一秒的值,那么不要统计进去,
 * 比如9s的时候400ms——600ms值是10,现在时间到10s余500ms了
 * 统计的时候不是10+1,而是1,因为都不同一秒了,因此滑动窗口的元素需要记下当前是第一秒的值
 */
public class CountFlowChecker3 {

    // AtomicReferenceArray 就是上面的滑动窗口,本质是一个数组
    //  AtomicStampedReference中stamp记录是第几秒的值,Integer记录数量
    private AtomicReferenceArray<AtomicStampedReference<Integer>> slideWindow;
    // 最大qps
    private int max;
    // 把一秒分为多少份!
    private int arrayLength;
    // 一份是多少ms
    private int intervalDuration;

    public CountFlowChecker3(int max, int arrayLength) {
        this.max = max;
        this.arrayLength = arrayLength;
        this.slideWindow = new AtomicReferenceArray<>(arrayLength);
        // 这里可能存在没办法整除的情况,不是本文的主题,暂不做考虑
        this.intervalDuration = 1000 / arrayLength;
    }

    public boolean isAllow(int acquire) {
        if (acquire > max) {
            return false;
        }

        while (true) {
            //  当前时间
            long currentMilliSeconds = currentMilliSeconds();
            int currentSeconds = (int) (TimeUnit.MILLISECONDS.toSeconds(currentMilliSeconds) % Integer.MAX_VALUE);
            // 对应在滑动窗口的位置
            int index = (int) (currentMilliSeconds%1000 / this.intervalDuration);
            // 求和
            long preSum = sum(currentSeconds);
            // 超出限流
            if (preSum + acquire >= max) {
                return false;
            }
            // 获取当前位置的引用
            AtomicStampedReference<Integer> element = slideWindow.get(index);
            // 如果没有初始化
            if (Objects.isNull(element)) {
                if (slideWindow.compareAndSet(index, null, new AtomicStampedReference<>(acquire, currentSeconds))) {
                    return true;
                }
            }
            // 刷新一下,因为这时候maybe被其他线程初始化了
            element = slideWindow.get(index);
            // 是同一秒,那么+,如果不是那么覆盖
            int sourceSeconds = element.getStamp();
            int updateValue = sourceSeconds == currentSeconds ? element.getReference() + acquire : acquire;
            if (element.compareAndSet(element.getReference(),updateValue,sourceSeconds,currentSeconds)) {
                return true;
            }
            

        }

    }


    private long sum(int currentSeconds) {
        int sum = 0;
        for (int i = 0; i < slideWindow.length(); i++) {
            AtomicStampedReference<Integer> element = slideWindow.get(i);
            // 是同一秒的值才统计!
            if (Objects.nonNull(element) && element.getStamp() == currentSeconds
                    && Objects.nonNull(element.getReference())) {
                sum = element.getReference();
            }
        }
        return sum;
    }

    private static long currentMilliSeconds() {
        return System.currentTimeMillis();
    }

}

可以看到滑动窗口统计完多个窗口值后,如果判断可以继续通过那么也是进行cas更新,统计sum和后面的cas也不具备一致性

并且同样可以使用LongAdder优化热点数据竞争的问题,比如下优化,代码类似于2.2.2

image-20240114003802707

2.5 令牌桶算法

请求执行作为消费者,每个请求都需要去桶中拿取一个令牌,取到令牌则继续执行;如果桶中无令牌可取,就触发拒绝策略,可以是超时等待,也可以是直接拒绝本次请求,由此达到限流目的。当桶中令牌数大于最大数量的时候,将不再添加。它可以适应流量突发,N 个请求到来只需要从桶中获取 N 个令牌就可以继续处理。

image-20230216223329049

import com.google.common.util.concurrent.AtomicDouble;

public class CountFlowChecker4 {
    // 最大qps
    private final double maxTokens;

    // 上一次可用的tokens
    //  com.google.common.util.concurrent.AtomicDouble;
    // 使用doubleToRawLongBits和longBitsToDouble进行double的转换
    private final AtomicDouble availableTokens;
    // 上一次填充的间隔
    private volatile long lastRefillTimeStamp;

    public CountFlowChecker4(double maxTokens) {
        this.maxTokens = maxTokens;
        this.availableTokens = new AtomicDouble(maxTokens);
        this.lastRefillTimeStamp = System.currentTimeMillis();
    }

    public boolean isAllow(int acquire) {
        if (acquire > maxTokens) {
            return false;
        }
        long now = System.currentTimeMillis();
        // 尝试根据时间重新填充令牌
        refill(now);

        double currentTokens = availableTokens.get();
        // 如果没有足够的令牌,则立即返回false,不阻塞
        if (currentTokens < acquire) {
            return false;
        }

        // 如果令牌数量足够,则使用CAS减少一个令牌
        return availableTokens.compareAndSet(currentTokens, currentTokens - acquire);
    }

    private void refill(long now) {
        double tokensToAdd = (((double) (now - lastRefillTimeStamp)) / 1000 * maxTokens);
        double preCount = availableTokens.get();
        double newTokenCount = Math.min(maxTokens, preCount + tokensToAdd);

        // 使用CAS更新令牌数量,如果失败则忽略(其他线程可能已经更新了)
        if (tokensToAdd > 0) {
            if (availableTokens.compareAndSet(preCount, newTokenCount)) {
                // 这里不需要纠结lastRefillTimeStamp 和 availableTokens更新的原子性
                // 因为lastRefillTimeStamp 记录的是上一次更新时间
                // 如果当前线程成功,那么就更新吧
                lastRefillTimeStamp = now;
            }
        }
    }
}

令牌桶算法实现也并没有太复杂,而且这里使用的是动态计算令牌数据,可以看出适应流量突发,一瞬间可用给出全部的令牌,甚至还可以积攒令牌应对并发,但是这种允许突发流量对于下游是不是不太友好。

2.6 漏桶算法

漏桶限流算法的核心就是, 不管上面的水流速度有多块, 漏桶水滴的流出
速度始终保持不变
,不论进入的流量有多么不规则,流量的离开速率却始终保持恒定

在漏桶算法中,有一个固定容量的桶,请求(类似水)以任意速率流入桶内,而桶以恒定速率往外“漏”出请求。如果桶满了,进来的新请求会丢弃或排队等待。

image-20230216223159914

通常实际应用是通过定时器任务实现漏桶的“漏水”操作,即定时任务线程定时从桶中获取任务进行执行,理论上使用一个阻塞队列+调度线程池可进行实现。

漏桶算法在需要及时响应的场景下不是很友好,任务如果被提交到桶,调用方却超时了那么任务处理也没啥意义了,和本地场景不是很符合。

3.笔者的思考

3.1 限流器算法比较

  • 计数器(Fixed Window Counter)

    最简单的限流算法,基于一个固定的时间窗口(比如每秒),统计请求的数量,当请求数量超出阈值时,新的请求将被拒绝或者排队。


    • 优点:实现简单,容易理解。
    • 缺点:在时间窗口边界处存在突发请求量的问题,即窗口重置时可能会突然允许大量请求通过,从而导致短时间的高流量。
  • 滑动窗口(Sliding Window Log)

    滑动窗口算法是对计数器算法的一种改进,它考虑了时间窗口中的每个小间隔。这些间隔可以是过去几秒、几分的N个桶,算法根据请求到达的时间进行统计,使得限流更加平滑。


    • 优点:相比固定窗口算法,滑动窗口可以减少时间窗口边缘的突发流量问题。
    • 缺点:比固定窗口算法复杂,实现和维护成本更高。
  • 漏桶(Leaky Bucket)

    漏桶算法使用一个固定容量的桶来表示令牌或请求。请求按照固定的速率进入桶内,而桶按照固定的速率向外漏水(处理请求),当桶满时,多余的水(请求)会溢出(被拒绝或排队)。


    • 优点:能够以恒定的速率处理请求,避免了突发流量影响。
    • 缺点:即使网络状况良好,桶的出水速率也是恒定的,这可能会导致一定程度上的资源浪费。
  • 令牌桶(Token Bucket)

    令牌桶算法维护一个令牌桶,桶内有一个固定容量的令牌。系统以恒定速率生成令牌到桶中,当请求到来时,如果桶内有令牌,则允许该请求通过,并消耗一个令牌;如果没有令牌,则请求被拒绝或排队。


    • 优点:能够允许一定程度的突发流量,因为可以累积令牌;处理请求的速率可以动态调整。
    • 缺点:实现比固定窗口计数器和漏桶算法复杂。

3.2 上述编码中的思想

  1. 【2.1.2解决
    [checkerMap读是没有竞争的,不需要加锁]
    的问题】

    读写分离的思想,如mysql中读取一般是不加锁的,我们在实际业务开发中读取数据也一般是不会加锁的!

  2. 【2.1.3 减小锁粒度】

    如mysql中的行锁和表锁,行锁提高了更高的并发度,这也是innodb优于其myisam的点

  3. 【2.2.2 借鉴LongAdder的思想,减少热点数据竞争】

    热点数据分离,比如redis中的热key释放可拆分为key1,key2进行热点数据分离。

    比如大卖的商家,其订单流水分在多个表,分散热点避免单表性能瓶颈

  4. 【2.6 漏桶算法】

    类似消息的队列的削峰填谷,将请求放到消息队列,让消费者以合适的速率进行消费

3.3 分布式限流

如果机器有500台,限流100qps怎么办?

  • Sentinel提供了一种分布式限流,核心是选取一台机器作为leader,其他机器调用的时候需要发送请求申请令牌,leader负责进行统计。但是一般建议使用,因为具备单点故障的问题,而且也不够去中心化。
  • redis实现,每一台机器都需要访问redis进行读写操作,热key问题,并且徒增rt,不太划算

我们在双11大促的时候,会进行流量评估,一般不建议单机qps不能小于5,小于5的限流容易出现误杀,不太具备现实意义。

因此如果流量负载均衡,那么建议优化为单机限流,使用sentinel or guava的RateLimiter

但是笔者有一个项目,算法提供AIGC服务,机器只有100台,且每一台并发度为1,服务端有500台,但是笔者是离线定时任务调用AIGC服务,所以我使用了分布式调度map-reduce,使用调度机器分配任务到100台服务端机器上,服务端机器单机串行从而控制qps!

中国.NET 社区2023年12月16日 在北京成功举办了.NET Conf China 2023,虽然北京飘起雪,依然挡不住想要参加活动的全国各地的.NET开发兄弟姐妹的热情。大家可以通过大会精彩照片集:
https://live.photoplus.cn/live/79415183
体验现场的热度。欢迎访问大会官网了解更详细的信息:

https://dotnetconf.cn/

在全国各地还有非常多的兄弟姐妹们错过了北京的聚会,大会组委会在全国各大城市开启.NET Conf China 2023 Party ,今天开启的是深圳站活动,报名人数超过了100,1.28日武汉站的活动正在报名:
https://mp.weixin.qq.com/s/MNZSfWOQPDnTm3Kejkrreg

图片

本期活动是由微软最有价值专家中国项目组、微软Reactor、中国.NET社区和深圳.NET俱乐部联合主办,将给您带来全新的期待和不一样的体验,同时提供线上直播。

CSDN直播:
https://live.csdn.net/room/shanyou/B78Q9RrM

视频号直播: 请扫下方二维码预约。

image

欢迎大家参加.NET 生态调研,调研链接:
https://aka.ms/dotnet-survey-zh

image

特别鸣谢:

  • 深圳大童 提供场地、茶歇、礼品和人员支持
  • 中国.NET社区 提供礼品支持
  • ABP 社区 提供礼品支持