给一个全限定类名,输出一段能直接渲染的 PlantUML。单文件、253 行、零依赖。 本文是 如何自动生成简单的 PlantUML 类图 的重写版:工具没变,补上的是原来没讲的原理、怎么看图,以及它会在哪里骗你。
为什么要自动画类图
读 JDK 源码时,我们常常需要弄清楚这个类在继承体系里的位置。
手画流程图的过程可能是这样的:打开源码,看 extends,看 implements,再去看父类的父类......画完一张图,半天过去了;一个地方画错了,还得调整。
先看结果
下面这张图覆盖了 HashMap、LinkedHashMap、ConcurrentHashMap,由一条命令生成:
bash
java ClassDiagramGenerator 'java.util.HashMap' 'java.util.LinkedHashMap' 'java.util.concurrent.ConcurrentHashMap'

它输出的是文本而不是图片,上面那张图是借助 IntelliJ IDEA 里的 PlantUML 插件渲染出来的。文本长这样 ⬇️
text
@startuml
interface java.lang.Cloneable
interface java.io.Serializable
interface java.util.concurrent.ConcurrentMap
interface java.util.SequencedMap
interface java.util.Map
abstract java.util.AbstractMap
class java.util.HashMap
class java.util.LinkedHashMap
class java.util.concurrent.ConcurrentHashMap
java.util.Map <|.. java.util.AbstractMap
java.util.AbstractMap <|-- java.util.HashMap
java.util.Map <|.. java.util.HashMap
java.lang.Cloneable <|.. java.util.HashMap
java.io.Serializable <|.. java.util.HashMap
java.util.Map <|-- java.util.SequencedMap
java.util.HashMap <|-- java.util.LinkedHashMap
java.util.SequencedMap <|.. java.util.LinkedHashMap
java.util.Map <|-- java.util.concurrent.ConcurrentMap
java.util.AbstractMap <|-- java.util.concurrent.ConcurrentHashMap
java.util.concurrent.ConcurrentMap <|.. java.util.concurrent.ConcurrentHashMap
java.io.Serializable <|.. java.util.concurrent.ConcurrentHashMap
@enduml
9 个类型、12 条关系,图里能直接读出三件事:
- 三个
Map都继承(或者间接继承)AbstractMap,但LinkedHashMap是间接 继承 ------ 它先继承HashMap; ConcurrentHashMap实现的是ConcurrentMap而不是Map,而ConcurrentMap本身是Map的子接口;Cloneable/Serializable这两个标记接口挂在最外围,剪掉之后图会干净很多(就是后面-i干的事)。
它是怎么工作的
整个工具只做三件事。
第一步:把类名变成 Class 对象
java
Class<?> clazz = Class.forName(className);
Class.forName 要的是全限定名 :java.util.HashMap 可以,HashMap 不行;内部类写成 java.util.Arrays$ArrayList。
第二步:递归收集父类和接口
全部逻辑的核心不到 30 行 ⬇️
java
private void analyzeHierarchy(Class<?> currClass) {
if (!analyzedClasses.contains(currClass) && !ignoredClasses.contains(currClass)) {
analyzeSuperClass(currClass);
analyzeInterfaces(currClass);
analyzedClasses.add(currClass);
}
}
private void analyzeSuperClass(Class<?> currClass) {
Class<?> superclass = currClass.getSuperclass();
if (superclass == null || ignoredClasses.contains(superclass)) {
return;
}
analyzeHierarchy(superclass);
if (!inheritanceRelations.containsKey(currClass)) {
inheritanceRelations.put(currClass, superclass);
}
}
private void analyzeInterfaces(Class<?> currClass) {
Class<?>[] interfaces = currClass.getInterfaces();
for (Class<?> item : interfaces) {
analyzeHierarchy(item);
}
// ...
}
analyzedClasses 承担了"去重"和"控制输出顺序"两个职责:它是 LinkedHashSet,所以同一个输入每次输出都一样。
第三步:分两类关系打印
继承和实现分开存:inheritanceRelations 记录 类 → 父类,realizationRelations 记录 类 → 接口数组,最后统一打印。
java
analyzedClasses.forEach(c -> {
if (inheritanceRelations.containsKey(c)) {
System.out.printf("%s <|-- %s%n", inheritanceRelations.get(c).getName(), c.getName());
}
if (realizationRelations.containsKey(c)) {
String type = c.isInterface() ? "<|--" : "<|..";
// ...
}
});
三个设计选择
为什么默认排除 Object。 每个类最终都继承 Object,画出来就是所有节点连一条线到同一个角,纯噪音。构造函数里一句 ignoredClasses.add(Object.class) 就够了。
为什么要有 -i 选项。 Cloneable、Serializable 这类标记接口没有方法,对理解结构没有帮助,但几乎每个集合类都实现它们。-i 就是用来剪枝的。
剪掉的类不能凭空消失。 用了 -i 之后,图里会多一段图例,写明哪些类型被剪掉了 ------ 否则读者会以为这个类真的没实现 Serializable:
text
legend left
请注意: 以下类/接口在类图中被忽略了
<i>java.lang.Cloneable</i>
<i>java.io.Serializable</i>
end legend
怎么用
bash
javac ClassDiagramGenerator.java
# 基本用法
java ClassDiagramGenerator 'java.util.ArrayList'
# 忽略两个标记接口
java ClassDiagramGenerator -i 'java.lang.Cloneable' -i 'java.io.Serializable' 'java.util.ArrayList'
两条参数规则:
- 至少要给一个类名;
-i必须写在所有类名之前 。解析器只从参数开头连续读取-i,写在后面会被当成类名,直接报Class with name=-i can't be found。
例 1:ArrayList
bash
java ClassDiagramGenerator -i 'java.lang.Cloneable' -i 'java.io.Serializable' 'java.util.ArrayList'

这张图回答了一个常见问题:ArrayList 为什么能当 List、Collection、Iterable 用?因为它实现了 List,而 List 又依次继承了 SequencedCollection → Collection → Iterable。
例 2:EnumSet 的两个实现
bash
java ClassDiagramGenerator 'java.util.JumboEnumSet' 'java.util.RegularEnumSet'

EnumSet 是抽象类,of() 会按枚举常量的个数挑实现:不超过 64 个走 RegularEnumSet(一个 long 当位图),超过就走 JumboEnumSet(long[])。
例 3:自己写的类
java
interface Drawable { void draw(); }
interface Named { String name(); }
abstract class Shape implements Drawable, Named {
abstract double area();
}
class Circle extends Shape {
double area() { return Math.PI; }
public void draw() { }
public String name() { return "circle"; }
}
class Rectangle extends Shape {
double area() { return 1; }
public void draw() { }
public String name() { return "rectangle"; }
}
enum Color { RED, GREEN, BLUE }
bash
javac Shapes.java
java ClassDiagramGenerator Shape Circle Rectangle Color

这张最适合拿来对照"怎么读图":一个枚举、两个接口、一个抽象类、两个实现,外加 Enum 自带的那串接口。
还能画什么
| 输入 | 说明 |
|---|---|
java.util.Arrays$ArrayList |
Arrays.asList(...) 返回的实例 |
java.util.List / java.util.Set / java.util.Deque |
只看这几个接口自己的继承关系 |
java.util.ImmutableCollections$Set12 |
Set.of(...) 返回的实例(1~2 个元素时) |
java.util.concurrent.ThreadPoolExecutor |
线程池的继承链,比想象中长 |
怎么读这张图
- 节点 :
C普通类,A抽象类,I接口,E枚举; - 实线三角 :继承(
extends),写作父 <|-- 子; - 虚线三角 :实现(
implements),写作接口 <|.. 类; - 方向:箭头永远从子指向父。
读的时候只问一个问题:这个方法是从哪来的? 顺着线往上走,第一个定义它的类就是答案。
它会在哪里骗你
这个工具有三个局限,不是 bug:
- 没有泛型。 输出的是
List而不是List<E>------ 泛型在运行时被擦除了,反射拿不到。 - 没有字段和方法。 只画类型之间的关系。字段其实能拿到(
getDeclaredFields()),但一旦画上去,图立刻变得不可读,所以我把它留成手工补。 - 只能看到 classpath 里的类。
java.*开箱即用,分析第三方库要先加-cp。
确实需要字段时(比如讲 HashMap 的五个字段),我觉得一种可行的做法是:先生成骨架,再手改那一处 ⬇️
text
class java.util.HashMap {
- Node[] table
- int size
- int threshold
- float loadFactor
- int modCount
}
掘金不渲染 puml:把生成的文本贴到 PlantUML 的在线编辑器(或 Intellij IDEA 的插件)里就能看到图。同系列还有一篇 Mermaid 版,语法不同、思路一样。
完整代码
请将以下代码保存为 ClassDiagramGenerator.java ⬇️
java
import java.lang.reflect.AccessFlag;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class ClassDiagramGenerator {
private final Map<Class<?>, Class<?>[]> realizationRelations = new HashMap<>();
private final Map<Class<?>, Class<?>> inheritanceRelations = new HashMap<>();
private final Set<Class<?>> analyzedClasses = new LinkedHashSet<>();
private final Set<Class<?>> ignoredClasses = new HashSet<>();
private static final Class<Object> OBJECT_CLASS = Object.class;
private static final String IGNORE_OPTION = "-i";
private static final String USAGE = "Usage: java ClassDiagramGenerator [-i 'java.io.Serializable'] [-i 'java.lang.Cloneable'] 'java.util.ArrayList' 'java.util.LinkedList'";
public static void main(String[] args) {
ClassDiagramGenerator generator = new ClassDiagramGenerator();
generator.convert(args).forEach(generator::analyzeHierarchy);
generator.generateClassDiagram();
}
public ClassDiagramGenerator() {
ignoredClasses.add(OBJECT_CLASS);
}
private void ignoreClass(String className) {
try {
Class<?> clazz = Class.forName(className);
ignoredClasses.add(clazz);
} catch (ClassNotFoundException e) {
String message = String.format("Class with name=%s can't be found, please check!", className);
throw new RuntimeException(message);
}
}
/**
* Convert class name to the corresponding class object.
* The "-i" option (it means some class should be ignored) will be handled in this method.
*
* @param classNames give class names
* @return a list that contains corresponding class objects
*/
private List<Class<?>> convert(String[] classNames) {
int index = 0;
while (index < classNames.length && classNames[index].equals(IGNORE_OPTION)) {
if (index + 1 >= classNames.length) {
String hint = "Please refer to below usage and specify correct parameter with the '-i' option!";
throw new IllegalArgumentException(String.join(System.lineSeparator(), hint, USAGE));
}
ignoreClass(classNames[index + 1]);
index += 2;
}
if (index == classNames.length) {
String hint = "Please refer to below usage and specify at least ONE class name!";
throw new IllegalArgumentException(String.join(System.lineSeparator(), hint, USAGE));
}
List<Class<?>> classList = new ArrayList<>(classNames.length);
for (; index < classNames.length; index++) {
String className = classNames[index];
try {
Class<?> clazz = Class.forName(className);
classList.add(clazz);
} catch (ClassNotFoundException e) {
String message = String.format("Class with name=%s can't be found, please check!", className);
throw new RuntimeException(message);
}
}
return classList;
}
private void showIgnoredClasses() {
List<Class<?>> classes = ignoredClasses.stream().
filter(c -> c != OBJECT_CLASS).
toList();
if (!classes.isEmpty()) {
printAnEmptyLine();
System.out.println("legend left");
System.out.println("请注意: 以下类/接口在类图中被忽略了");
classes.forEach(c -> System.out.println("<i>" + c.getName() + "</i>"));
System.out.println("end legend");
}
}
/**
* Generate first line for PlantUML class diagram
*/
private void generateFirstLine() {
System.out.println("@startuml");
}
/**
* Generate last line for PlantUML class diagram
*/
private void generateLastLine() {
System.out.println("@enduml");
}
/**
* Generate main content in PlantUML class diagram
*/
private void generateClassDiagram() {
generateFirstLine();
printAnEmptyLine();
doGenerateClassDiagram();
showIgnoredClasses();
printAnEmptyLine();
generateLastLine();
}
private void printAnEmptyLine() {
System.out.println();
}
private void doGenerateClassDiagram() {
generateNames();
analyzedClasses.forEach(c -> {
if (inheritanceRelations.containsKey(c)) {
System.out.printf("%s <|-- %s%n", inheritanceRelations.get(c).getName(), c.getName());
}
if (realizationRelations.containsKey(c)) {
String type = c.isInterface() ? "<|--" : "<|..";
Arrays.stream(realizationRelations.get(c)).forEach(item ->
System.out.printf("%s %s %s%n", item.getName(), type, c.getName()));
}
});
}
/**
* This method generates names for
* 1. Enum classes
* 2. Annotation classes
* 3. Interfaces
* 4. Abstract classes
* 5. Other classes
*/
private void generateNames() {
Set<Class<?>> enums = analyzedClasses.stream()
.filter(Class::isEnum)
.collect(Collectors.toSet());
// note: each annotation will appear in "annotations" and "interfaces" and "abstractClasses"
Set<Class<?>> annotations = analyzedClasses.stream()
.filter(Class::isAnnotation)
.collect(Collectors.toSet());
// note: each interface will appear in "interfaces" and "abstractClasses"
Set<Class<?>> interfaces = analyzedClasses.stream()
.filter(Class::isInterface)
.collect(Collectors.toSet());
Set<Class<?>> abstractClasses = analyzedClasses.stream()
.filter(this::isAbstractClass)
.collect(Collectors.toSet());
generateEnumNames(enums);
generateAnnotationNames(annotations);
generateInterfaceNames(interfaces, annotations);
generateAbstractClassNames(abstractClasses, enums, interfaces);
generateRemainingClassNames(List.of(enums, abstractClasses, interfaces));
}
private boolean isAbstractClass(Class<?> candidate) {
return candidate.accessFlags().contains(AccessFlag.ABSTRACT);
}
private void generateEnumNames(Set<Class<?>> enums) {
Consumer<Class<?>> enumDisplayer = (annotationItem) ->
System.out.println("enum " + annotationItem.getName());
enums.forEach(enumDisplayer);
}
private void generateAnnotationNames(Set<Class<?>> annotations) {
Consumer<Class<?>> interfaceDisplayer = (annotationItem) ->
System.out.println("annotation " + annotationItem.getName());
annotations.forEach(interfaceDisplayer);
}
private void generateInterfaceNames(Set<Class<?>> interfaces, Set<Class<?>> annotations) {
Consumer<Class<?>> interfaceDisplayer = (interfaceItem) ->
System.out.println("interface " + interfaceItem.getName());
interfaces.stream()
.filter(interfaceItem -> !annotations.contains(interfaceItem))
.forEach(interfaceDisplayer);
}
private void generateAbstractClassNames(Set<Class<?>> abstractClasses, Set<Class<?>> enums, Set<Class<?>> interfaces) {
Consumer<Class<?>> abstractClassDisplayer = (abstractClass) ->
System.out.println("abstract " + abstractClass.getName());
abstractClasses.stream()
.filter(abstractClass -> !enums.contains(abstractClass))
.filter(abstractClass -> !interfaces.contains(abstractClass))
.forEach(abstractClassDisplayer);
}
private void generateRemainingClassNames(List<Set<Class<?>>> alreadyProcessedClasses) {
Predicate<Class<?>> alreadyProcessed = (clazz) ->
alreadyProcessedClasses.stream().anyMatch(classes -> classes.contains(clazz));
analyzedClasses.stream()
.filter(item -> !alreadyProcessed.test(item))
.map(Class::getName)
.forEach(className -> System.out.println("class " + className));
}
private void analyzeHierarchy(Class<?> currClass) {
if (!analyzedClasses.contains(currClass) && !ignoredClasses.contains(currClass)) {
analyzeSuperClass(currClass);
analyzeInterfaces(currClass);
analyzedClasses.add(currClass);
}
}
private void analyzeSuperClass(Class<?> currClass) {
Class<?> superclass = currClass.getSuperclass();
if (superclass == null || ignoredClasses.contains(superclass)) {
return;
}
analyzeHierarchy(superclass);
if (!inheritanceRelations.containsKey(currClass)) {
inheritanceRelations.put(currClass, superclass);
}
}
private void analyzeInterfaces(Class<?> currClass) {
Class<?>[] interfaces = currClass.getInterfaces();
for (Class<?> item : interfaces) {
analyzeHierarchy(item);
}
if (!realizationRelations.containsKey(currClass)) {
Class<?>[] filteredInterfaces = Arrays.stream(interfaces).
filter(c -> !ignoredClasses.contains(c)).
toArray(Class<?>[]::new);
realizationRelations.put(currClass, filteredInterfaces);
}
}
}
小结
- 类图自动化的最小闭环就三步:
Class.forName→ 递归getSuperclass/getInterfaces→ 按继承、实现两类打印。 - 输出顺序要稳定(
LinkedHashSet),否则每次diff都是噪声。 Object默认排除,标记接口用-i剪掉,剪掉的必须写进图例。- 它画的是骨架:泛型、字段、方法都不在范围内,需要时手工补。
- 它真正的价值不是「省一次画图的时间」,而是可复现:同一条命令,任何时候都能得到同一张图。
参考资料
- 原版(本文的重写对象):如何自动生成简单的 PlantUML 类图
- 同系列 Mermaid 版:如何自动生成简单的 Mermaid 类图
- 用 CLI 展示 EnumSet 的内部变化:上 / 下
- PlantUML 官网:plantuml.com/class-diagr...
版权问题
本文展示的代码是我自己写的,读者可以自由复制、修改、再发布,不必通知我。