背景
我之前写过 Java 展示添加元素时 ArrayList 的内部变化,用来展示添加元素时 ArrayList 的内部变化。后来我想到 LinkedList 在增删元素时,我们也可以用 GUI 的方式来呈现其内部的变化。于是我借助 trae 完成了这样的 java 代码。
注意事项
完整代码 里用到了 java 21 新增的 API,在您编译和运行本文提供的 java 代码时,请确保您所使用的 javac/java 版本不低于 21。

正文
如何运行
为了行文连贯,我把完整的代码放到 这一小节 里了,请将完整的代码保存为 LinkedListBehaviorDemo.java。使用如下命令可以编译 LinkedListBehaviorDemo.java。
bash
javac LinkedListBehaviorDemo.java
编译之后,会得到以下 class 文件
LinkedListBehaviorDemo.classLinkedListBehaviorDemo$ChainView.classLinkedListBehaviorDemo$NodeView.class
使用如下命令,可以运行 LinkedListBehaviorDemo 中的 main 方法。
bash
java --add-opens=java.base/java.util=ALL-UNNAMED LinkedListBehaviorDemo
运行效果示例
初始效果

构造函数
可以使用默认构造函数 ⬇️

添加元素
通过点击以下按钮,我们可以向 LinkedList 中添加一个元素
add("A")按钮(按钮中的字符串会不断变化,最初是"A")addFirst("A")按钮(按钮中的字符串会不断变化,最初是"A")add(i, "A")按钮(按钮中的字符串会不断变化,最初是"A")

点击这些按钮后,LinkedList 里的对应方法会被调用 ⬇️

添加元素后的示例效果如下 ⬇️

删除元素
通过点击以下按钮,我们可以从 LinkedList 中删除一个元素
removeFirst()按钮removeLast()按钮remove(i)按钮

点击这些按钮后,LinkedList 里的对应方法会被调用 ⬇️

Show logical chain 按钮
这个按钮可以展示各个节点逻辑上的前后顺序(会将 next 引用以及 prev 引用都展示出来)

Show heap scatter 按钮
这个按钮用于展示各个节点的分布(所展示的并非各个节点真实的地址)。我们可以指定展示各个节点的 next 引用

也可以指定展示各个节点的 prev 引用 
Reset 按钮
这个按钮用于重新开始
Current state
这个控件里会展示 LinkedList 的 size/first/last 三个字段

size/first/last 来自 LinkedList ⬇️

完整的代码
java
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Stroke;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.QuadCurve2D;
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingUtilities;
import javax.swing.border.Border;
import javax.swing.border.TitledBorder;
/**
* LinkedList behavior demo: visualizes the private internals of
* java.util.LinkedList while add / remove are called.
* <p>
* Same principle as ArrayListBehaviorDemo, but the interesting fields are
* different. LinkedList keeps:
* <pre>
* transient Node<E> first;
* transient Node<E> last;
* transient int size;
* </pre>
* and every node is a separate object:
* <pre>
* private static class Node<E> { E item; Node<E> next; Node<E> prev; }
* </pre>
* The nodes are NOT stored in one contiguous array: each one is an
* independent heap object, and the list order exists only in the
* next/prev references. The GUI therefore draws two things at once:
* <ul>
* <li>the logical chain (box per node, forward next arrow, backward
* prev arrow, null terminated at both ends);</li>
* <li>an optional scattered layout, where each node is placed at a
* pseudo-position derived from its identity hash, so it becomes
* visible that neighbors in the list are not neighbors in memory.</li>
* </ul>
* Observable facts shown by the demo:
* <ol>
* <li>add(E) / addLast(E) links a new node after "last";</li>
* <li>addFirst(E) links a new node before "first";</li>
* <li>add(i, E) first calls node(i) and walks the links, then re-links
* two neighbors (index == size skips the walk and links at the tail);</li>
* <li>removeFirst() / removeLast() unlink one end;</li>
* <li>remove(i) also walks with node(i), then unlinks;</li>
* <li>both walks go forward from first via next when i < size/2, otherwise
* backward from last via prev, so reaching an index costs O(n) - the
* reason ArrayList wins at random access.</li>
* </ol>
* On JDK 9+ run with:
* javac LinkedListBehaviorDemo.java
* java --add-opens=java.base/java.util=ALL-UNNAMED LinkedListBehaviorDemo
*/
public class LinkedListBehaviorDemo {
/**
* Value used by the next insert. Distinct values make the insertion
* position readable: after inserting "C" you see A, B, C, D.
*/
private int nextLabel;
// ---- chain layout (one box per node, laid out in list order) ----
private static final int NODE_W = 96;
private static final int NODE_H = 40;
private static final int CHAIN_GAP = 58;
private static final int CHAIN_PITCH = NODE_W + CHAIN_GAP;
private static final int CHAIN_LEFT = 84;
private static final int CHAIN_TOP = 78;
// ---- scattered layout (nodes at pseudo heap positions) ----
private static final int SCATTER_COLS = 5;
private static final int SLOT_W = 148;
private static final int SLOT_H = 98;
private static final int MIN_SCATTER_ROWS = 2;
private static final Color NODE_COLOR = new Color(66, 133, 244);
private static final Color OUTLINE_COLOR = new Color(170, 170, 170);
private static final Color TOUCHED_COLOR = new Color(244, 180, 0);
/** next (forward) links: navy, so the two directions stay distinguishable. */
private static final Color NEXT_COLOR = new Color(26, 60, 130);
/** prev (backward) links: magenta. */
private static final Color PREV_COLOR = new Color(173, 20, 87);
private static final Color FAINT_COLOR = new Color(210, 210, 210);
/** Blue for buttons that map 1:1 to Java code (IDE syntax-highlight blue). */
private static final Color CODE_COLOR = new Color(21, 101, 192);
/** Dark gray for demo helper buttons: clearly secondary. */
private static final Color DEMO_COLOR = new Color(93, 93, 93);
/** Rejected operations: the same red is used for the banner and the offending control. */
private static final Color REJECT_COLOR = new Color(198, 40, 40);
private static final Border GROUP_BORDER = BorderFactory.createEtchedBorder();
private static final Border GROUP_REJECT_BORDER = BorderFactory.createLineBorder(REJECT_COLOR, 2);
/** Dashed shaft for null terminators: a dashed line "leads nowhere", unlike a solid reference. */
private static final Stroke NULL_STROKE = new BasicStroke(1.5f, BasicStroke.CAP_BUTT,
BasicStroke.JOIN_MITER, 10f, new float[]{4f, 3f}, 0f);
/** Solid shaft for links between two real nodes. */
private static final Stroke LINK_STROKE = new BasicStroke(1.7f);
private final JFrame frame = new JFrame("LinkedList Behavior Demo");
/**
* The list under observation. Deliberately null until the constructor
* button is pressed, so "no instance yet" is a visible state too.
*/
private LinkedList<String> list;
/** Highlight: index of the node created by the last mutating call. */
private int lastTouchedIndex = -1;
private boolean scatterLayout;
/** Which link direction the scatter view draws; meaningless in the chain view. */
private boolean scatterShowPrev;
private final Field firstField;
private final Field sizeField;
private final Field itemField;
private final Field nextField;
private JButton addButton;
private JButton constructorButton;
private JButton addFirstButton;
private JButton addAtButton;
private JButton removeFirstButton;
private JButton removeLastButton;
private JButton removeAtButton;
private JButton layoutButton;
private JRadioButton nextLinkRadio;
private JRadioButton prevLinkRadio;
private JButton resetButton;
/** One index control per row, so a button only ever reads the "i" shown next to it. */
private JSpinner addIndexSpinner;
private JSpinner removeIndexSpinner;
/** The bordered containers above, needed to mark the offending index control red. */
private JPanel addIndexGroup;
private JPanel removeIndexGroup;
/** Banner that repeats a rejected operation until the next successful one. */
private JLabel rejectLabel;
private JLabel sizeLabel;
private JLabel firstLabel;
private JLabel lastLabel;
private JTextArea logArea;
private ChainView canvas;
public LinkedListBehaviorDemo() throws ReflectiveOperationException {
firstField = LinkedList.class.getDeclaredField("first");
sizeField = LinkedList.class.getDeclaredField("size");
// Node is a private static nested class, so its fields are read
// through the class of the "first" field.
Class<?> nodeClass = firstField.getType();
itemField = nodeClass.getDeclaredField("item");
nextField = nodeClass.getDeclaredField("next");
for (Field f : new Field[]{firstField, sizeField, itemField, nextField}) {
f.setAccessible(true);
}
buildUi();
refreshView();
}
// ======================================================================
// UI
// ======================================================================
private void buildUi() {
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(1000, 820);
frame.setLocationRelativeTo(null);
JPanel control = new JPanel();
control.setLayout(new BoxLayout(control, BoxLayout.Y_AXIS));
Font codeFont = new Font(Font.MONOSPACED, Font.BOLD, 13);
// ---------- row 1: constructor ----------
JPanel constructorRow = new JPanel(new FlowLayout(FlowLayout.LEFT));
constructorRow.setAlignmentX(Component.LEFT_ALIGNMENT);
constructorButton = new JButton("new LinkedList<String>()");
constructorButton.setFont(codeFont);
constructorButton.setToolTipText("Runs: new LinkedList<String>() - disabled while an "
+ "instance exists; use Reset to start over");
constructorRow.add(rowCaption("constructor", CODE_COLOR));
constructorRow.add(constructorButton);
control.add(constructorRow);
// ---------- row 2: add ----------
JPanel addRow = new JPanel(new FlowLayout(FlowLayout.LEFT));
addRow.setAlignmentX(Component.LEFT_ALIGNMENT);
addButton = new JButton();
addFirstButton = new JButton();
addAtButton = new JButton();
for (JButton b : new JButton[]{addButton, addFirstButton, addAtButton}) {
b.setFont(codeFont);
}
addButton.setToolTipText("Runs: list.add(value) - same as addLast: links a new node after the tail. "
+ "The value on the button is generated: A, B, C, ...");
addFirstButton.setToolTipText("Runs: list.addFirst(value): links a new node before the head");
addAtButton.setToolTipText("Runs: list.add(i, value) with i from the spinner on this row");
updateAddButtonTexts();
addRow.add(rowCaption("add", CODE_COLOR));
addRow.add(addButton);
addRow.add(addFirstButton);
addIndexSpinner = createIndexSpinner();
addIndexGroup = indexGroup(addAtButton, addIndexSpinner);
addRow.add(addIndexGroup);
control.add(addRow);
// ---------- row 3: remove ----------
JPanel removeRow = new JPanel(new FlowLayout(FlowLayout.LEFT));
removeRow.setAlignmentX(Component.LEFT_ALIGNMENT);
removeFirstButton = new JButton("removeFirst()");
removeLastButton = new JButton("removeLast()");
removeAtButton = new JButton("remove(i)");
for (JButton b : new JButton[]{removeFirstButton, removeLastButton, removeAtButton}) {
b.setFont(codeFont);
}
removeFirstButton.setToolTipText("Runs: list.removeFirst()");
removeLastButton.setToolTipText("Runs: list.removeLast()");
removeAtButton.setToolTipText("Runs: list.remove(i) with i from the spinner on this row");
removeRow.add(rowCaption("remove", CODE_COLOR));
removeRow.add(removeFirstButton);
removeRow.add(removeLastButton);
removeIndexSpinner = createIndexSpinner();
removeIndexGroup = indexGroup(removeAtButton, removeIndexSpinner);
removeRow.add(removeIndexGroup);
control.add(removeRow);
// ---------- row 4: demo only ----------
JPanel demoRow = new JPanel(new FlowLayout(FlowLayout.LEFT));
demoRow.setAlignmentX(Component.LEFT_ALIGNMENT);
layoutButton = new JButton("Show heap scatter");
layoutButton.setToolTipText("Demo helper, not a Java API call: draws the same list at "
+ "pseudo-positions derived from each node's identity hash, to show that "
+ "linked neighbours are not adjacent objects");
layoutButton.setForeground(DEMO_COLOR);
demoRow.add(rowCaption("demo only", DEMO_COLOR));
demoRow.add(layoutButton);
// link-direction choice, only meaningful while the scatter is shown
JLabel linksLabel = new JLabel("show links:");
nextLinkRadio = new JRadioButton("next", true);
prevLinkRadio = new JRadioButton("prev");
nextLinkRadio.setForeground(NEXT_COLOR);
prevLinkRadio.setForeground(PREV_COLOR);
ButtonGroup linkGroup = new ButtonGroup();
linkGroup.add(nextLinkRadio);
linkGroup.add(prevLinkRadio);
nextLinkRadio.addActionListener(e -> {
scatterShowPrev = false;
canvas.repaint();
});
prevLinkRadio.addActionListener(e -> {
scatterShowPrev = true;
canvas.repaint();
});
setLinkRadiosEnabled(false);
demoRow.add(Box.createHorizontalStrut(16));
demoRow.add(linksLabel);
demoRow.add(nextLinkRadio);
demoRow.add(prevLinkRadio);
resetButton = new JButton("Reset");
resetButton.setToolTipText("Demo helper, not a Java API call: discards the list, clears "
+ "the log and returns every control to the initial state "
+ "(only the constructor stays clickable)");
resetButton.setForeground(DEMO_COLOR);
demoRow.add(Box.createHorizontalStrut(16));
demoRow.add(resetButton);
control.add(demoRow);
// ---------- status ----------
JPanel statusRow = new JPanel(new FlowLayout(FlowLayout.LEFT));
statusRow.setAlignmentX(Component.LEFT_ALIGNMENT);
statusRow.setBorder(new TitledBorder("Current state"));
sizeLabel = new JLabel();
firstLabel = new JLabel();
lastLabel = new JLabel();
Font bold = new Font(Font.SANS_SERIF, Font.BOLD, 13);
for (JLabel l : new JLabel[]{sizeLabel, firstLabel, lastLabel}) {
l.setFont(bold);
}
statusRow.add(sizeLabel);
statusRow.add(Box.createHorizontalStrut(24));
statusRow.add(firstLabel);
statusRow.add(Box.createHorizontalStrut(24));
statusRow.add(lastLabel);
control.add(statusRow);
// ---------- rejected operation banner ----------
// Always allocated so the rows above do not jump when it appears;
// neutral and transparent until something is actually rejected.
JPanel rejectRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
rejectRow.setAlignmentX(Component.LEFT_ALIGNMENT);
rejectLabel = new JLabel(" ");
rejectLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
rejectLabel.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
rejectLabel.setOpaque(false);
rejectRow.add(rejectLabel);
control.add(rejectRow);
frame.add(control, BorderLayout.NORTH);
// ---------- center: the linked structure ----------
canvas = new ChainView();
JScrollPane canvasScroll = new JScrollPane(canvas);
canvasScroll.setBorder(new TitledBorder("LinkedList internals: first / last / next / prev"));
canvasScroll.getVerticalScrollBar().setUnitIncrement(16);
canvasScroll.getHorizontalScrollBar().setUnitIncrement(16);
frame.add(canvasScroll, BorderLayout.CENTER);
// ---------- bottom: operation log ----------
logArea = new JTextArea(9, 20);
logArea.setEditable(false);
logArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
JScrollPane logScroll = new JScrollPane(logArea);
logScroll.setBorder(new TitledBorder("Operation log"));
frame.add(logScroll, BorderLayout.SOUTH);
constructorButton.addActionListener(e -> doConstructor());
addButton.addActionListener(e -> doAdd());
addFirstButton.addActionListener(e -> doAddFirst());
addAtButton.addActionListener(e -> doAddAt());
removeFirstButton.addActionListener(e -> doRemoveFirst());
removeLastButton.addActionListener(e -> doRemoveLast());
removeAtButton.addActionListener(e -> doRemoveAt());
layoutButton.addActionListener(e -> toggleLayout());
resetButton.addActionListener(e -> doReset());
// Lifecycle: until a list exists, only the constructor can be clicked.
setOperationsEnabled(false);
}
/** Every control except the constructor: enabled only while a list exists. */
private void setOperationsEnabled(boolean enabled) {
for (JButton b : new JButton[]{addButton, addFirstButton, addAtButton,
removeFirstButton, removeLastButton, removeAtButton, layoutButton, resetButton}) {
b.setEnabled(enabled);
}
addIndexSpinner.setEnabled(enabled);
removeIndexSpinner.setEnabled(enabled);
setLinkRadiosEnabled(enabled && scatterLayout);
}
/** Left-hand caption of a button row; a fixed width keeps rows aligned. */
private static JLabel rowCaption(String text, Color color) {
JLabel label = new JLabel(text);
label.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 11));
label.setForeground(color);
label.setPreferredSize(new Dimension(78, 18));
return label;
}
private static JSpinner createIndexSpinner() {
JSpinner spinner = new JSpinner(new SpinnerNumberModel(0, 0, 99, 1));
spinner.setToolTipText("index i, read by the button on this row");
return spinner;
}
/**
* One compound control: an index-taking button together with the "i ="
* spinner it reads. Wrapping both in one bordered container makes the
* pairing part of the layout, instead of something the reader has to infer.
*/
private static JPanel indexGroup(JButton button, JSpinner spinner) {
JPanel group = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
group.setBorder(GROUP_BORDER);
JLabel label = new JLabel("i =");
label.setFont(new Font(Font.MONOSPACED, Font.BOLD, 13));
group.add(button);
group.add(label);
group.add(spinner);
return group;
}
// ======================================================================
// Operations
// ======================================================================
private void doConstructor() {
list = new LinkedList<>();
nextLabel = 0;
updateAddButtonTexts();
clearHighlights();
setOperationsEnabled(true);
constructorButton.setEnabled(false);
logLine(String.format("%-26s size=%d [empty: first = last = null]", "new LinkedList<String>()", 0));
refreshView();
}
private void doAdd() {
int oldSize = list.size();
String value = pendingValue();
list.add(value);
clearHighlights();
lastTouchedIndex = oldSize;
logLine(String.format("%-24s size %-10s [%s]",
"add(\"" + value + "\")",
oldSize + " -> " + list.size(),
"appended at tail: old last.next = new node, last = new node"));
advancePendingValue();
refreshView();
}
private void doAddFirst() {
int oldSize = list.size();
String value = pendingValue();
list.addFirst(value);
clearHighlights();
lastTouchedIndex = 0;
logLine(String.format("%-24s size %-10s [%s]",
"addFirst(\"" + value + "\")",
oldSize + " -> " + list.size(),
"prepended at head: new node.next = old first, first = new node"));
advancePendingValue();
refreshView();
}
private void doAddAt() {
int index = indexValue(addIndexSpinner);
int size = list.size();
if (index < 0 || index > size) {
logOutOfRange("add(" + index + ", \"" + pendingValue() + "\")", index, "0.." + size, addIndexGroup);
return;
}
String value = pendingValue();
String note;
if (index == size) {
note = "index == size, so it links at the tail (no walk)";
} else {
note = "node(" + index + "): " + walkDescription(index, size) + ", linked before it";
}
list.add(index, value);
clearHighlights();
lastTouchedIndex = index;
logLine(String.format("%-24s size %-10s [%s]",
"add(" + index + ", \"" + value + "\")",
size + " -> " + list.size(), note));
advancePendingValue();
refreshView();
}
private void doRemoveFirst() {
if (list.isEmpty()) {
reject("removeFirst()", "removeFirst() would throw NoSuchElementException", null);
return;
}
int oldSize = list.size();
list.removeFirst();
clearHighlights();
logLine(String.format("%-24s size %-10s [%s]",
"removeFirst()", oldSize + " -> " + list.size(),
"first = old first.next; the new first.prev = null"));
refreshView();
}
private void doRemoveLast() {
if (list.isEmpty()) {
reject("removeLast()", "removeLast() would throw NoSuchElementException", null);
return;
}
int oldSize = list.size();
list.removeLast();
clearHighlights();
logLine(String.format("%-24s size %-10s [%s]",
"removeLast()", oldSize + " -> " + list.size(),
"last = old last.prev; the new last.next = null"));
refreshView();
}
private void doRemoveAt() {
int size = list.size();
int index = indexValue(removeIndexSpinner);
if (size == 0) {
reject("remove(" + index + ")", "remove(i) cannot run on an empty list", null);
return;
}
if (index < 0 || index >= size) {
logOutOfRange("remove(" + index + ")", index, "0.." + (size - 1), removeIndexGroup);
return;
}
String note = "node(" + index + "): " + walkDescription(index, size) + ", unlinked";
list.remove(index);
clearHighlights();
logLine(String.format("%-24s size %-10s [%s]",
"remove(" + index + ")", size + " -> " + list.size(), note));
refreshView();
}
private void toggleLayout() {
scatterLayout = !scatterLayout;
layoutButton.setText(scatterLayout ? "Show logical chain" : "Show heap scatter");
setLinkRadiosEnabled(scatterLayout);
refreshView();
}
/** The link-direction radios only make sense while the scatter is visible. */
private void setLinkRadiosEnabled(boolean enabled) {
nextLinkRadio.setEnabled(enabled);
prevLinkRadio.setEnabled(enabled);
}
/**
* Full restart: unlike the constructor button (which only replaces the
* list), Reset also clears the log and returns every control - layout,
* radios, spinners - to the initial state.
*/
private void doReset() {
list = null;
nextLabel = 0;
updateAddButtonTexts();
addIndexSpinner.setValue(0);
removeIndexSpinner.setValue(0);
scatterLayout = false;
scatterShowPrev = false;
layoutButton.setText("Show heap scatter");
nextLinkRadio.setSelected(true);
setOperationsEnabled(false);
constructorButton.setEnabled(true);
clearHighlights();
logArea.setText("");
logGuidance();
refreshView();
}
// ======================================================================
// Walk model: mirrors AbstractSequentialList/LinkedList.node(int)
// ======================================================================
/** Explains which side the walk started from and how many nodes it touched. */
private static String walkDescription(int index, int size) {
int half = size >> 1;
boolean fromTail = index >= half;
int visited = fromTail ? size - index : index + 1;
return fromTail
? index + " >= (size >> 1 = " + half + "): " + visited + " node(s) back from last via prev"
: index + " < (size >> 1 = " + half + "): " + visited + " node(s) on from first via next";
}
// ======================================================================
// View helpers
// ======================================================================
private static int indexValue(JSpinner spinner) {
return (Integer) spinner.getValue();
}
/** Value the next insert will use: A, B, ... Z, A1, B1, ... */
private String pendingValue() {
return labelFor(nextLabel);
}
/** Consumes the pending value and shows the next one on the three "add" buttons. */
private void advancePendingValue() {
nextLabel++;
updateAddButtonTexts();
}
private static String labelFor(int n) {
char letter = (char) ('A' + n % 26);
int round = n / 26;
return round == 0 ? String.valueOf(letter) : letter + String.valueOf(round);
}
/** Keeps each "add" button showing the value it would insert. */
private void updateAddButtonTexts() {
String value = pendingValue();
addButton.setText("add(\"" + value + "\")");
addFirstButton.setText("addFirst(\"" + value + "\")");
addAtButton.setText("add(i, \"" + value + "\")");
}
/**
* A rejected call changes nothing, so it must not look like a success:
* it goes to the log, is repeated on the banner, and the control that
* caused it is outlined in red until the next successful call.
*/
private void reject(String op, String reason, JPanel offendingControl) {
logLine(String.format("%-24s rejected: %s", op, reason));
rejectLabel.setText("rejected " + op + " - " + reason);
rejectLabel.setOpaque(true);
rejectLabel.setBackground(REJECT_COLOR);
rejectLabel.setForeground(Color.WHITE);
if (offendingControl != null) {
offendingControl.setBorder(GROUP_REJECT_BORDER);
}
}
private void logOutOfRange(String op, int index, String range, JPanel offendingControl) {
reject(op, "index " + index + " is out of range (" + range + ")", offendingControl);
}
private void clearHighlights() {
lastTouchedIndex = -1;
addIndexGroup.setBorder(GROUP_BORDER);
removeIndexGroup.setBorder(GROUP_BORDER);
rejectLabel.setText(" ");
rejectLabel.setOpaque(false);
}
private void logLine(String text) {
logArea.append(text + System.lineSeparator());
logArea.setCaretPosition(logArea.getDocument().getLength());
}
private void refreshView() {
if (list == null) {
sizeLabel.setText("size: -");
firstLabel.setText("first: -");
lastLabel.setText("last: -");
canvas.setData(null);
return;
}
List<NodeView> nodes = snapshot();
sizeLabel.setText("size: " + nodes.size());
firstLabel.setText("first: " + describe(nodes, 0));
lastLabel.setText("last: " + describe(nodes, nodes.size() - 1));
canvas.setData(nodes);
}
private static String describe(List<NodeView> nodes, int index) {
if (index < 0 || index >= nodes.size()) {
return "null";
}
NodeView node = nodes.get(index);
return "\"" + node.item() + "\"";
}
/** Walks first -> next -> ... and records item plus identity hash per node. */
private List<NodeView> snapshot() {
List<NodeView> nodes = new ArrayList<>();
try {
int size = (int) sizeField.get(list);
Object node = firstField.get(list);
int guard = 0;
while (node != null && guard++ <= size) {
nodes.add(new NodeView((String) itemField.get(node),
System.identityHashCode(node)));
node = nextField.get(node);
}
} catch (IllegalAccessException e) {
throw new IllegalStateException(e);
}
return nodes;
}
/** One node of the chain, as read through reflection. */
private record NodeView(String item, int id) {
}
// ======================================================================
// Canvas
// ======================================================================
private final class ChainView extends JPanel {
private List<NodeView> nodes = new ArrayList<>();
ChainView() {
setBackground(Color.WHITE);
}
void setData(List<NodeView> nodes) {
this.nodes = nodes;
setPreferredSize(preferredSizeFor(nodes == null ? 0 : nodes.size()));
revalidate();
repaint();
}
private Dimension preferredSizeFor(int n) {
if (scatterLayout) {
int rows = Math.max(MIN_SCATTER_ROWS,
(n + SCATTER_COLS - 1) / SCATTER_COLS);
return new Dimension(SCATTER_COLS * SLOT_W + 24, rows * SLOT_H + 40);
}
return new Dimension(Math.max(660, CHAIN_LEFT + n * CHAIN_PITCH + 110), 236);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
if (nodes == null) {
paintNoInstanceMessage(g2);
return;
}
if (nodes.isEmpty()) {
paintEmptyMessage(g2);
return;
}
if (scatterLayout) {
paintScattered(g2);
} else {
paintChain(g2);
}
}
private void paintNoInstanceMessage(Graphics2D g2) {
g2.setColor(Color.GRAY);
g2.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 14));
g2.drawString("no LinkedList yet - press new LinkedList<String>() to create one", 24, 60);
}
private void paintEmptyMessage(Graphics2D g2) {
g2.setColor(Color.GRAY);
g2.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 14));
g2.drawString("empty list: first = last = null, size = 0 - "
+ "there are no node objects at all", 24, 60);
}
// ---------------- logical chain ----------------
private void paintChain(Graphics2D g2) {
int n = nodes.size();
int y = CHAIN_TOP;
int midY = y + NODE_H / 2;
// Links: they live inside the gaps, so they never cross a box.
// The two directions get their own color: next on top, prev below.
g2.setStroke(LINK_STROKE);
for (int i = 0; i < n - 1; i++) {
int x = CHAIN_LEFT + i * CHAIN_PITCH;
g2.setColor(NEXT_COLOR);
drawArrow(g2, x + NODE_W, y + 13, x + CHAIN_PITCH, y + 13); // next
g2.setColor(PREV_COLOR);
drawArrow(g2, x + CHAIN_PITCH, y + 27, x + NODE_W, y + 27); // prev
}
// null terminators: first.prev and last.next are null, each drawn in
// the color of the reference it stands for
int headX = CHAIN_LEFT;
int tailX = CHAIN_LEFT + (n - 1) * CHAIN_PITCH + NODE_W;
g2.setStroke(NULL_STROKE);
g2.setColor(PREV_COLOR);
drawArrow(g2, headX - 2, midY, headX - 40, midY);
g2.setColor(NEXT_COLOR);
drawArrow(g2, tailX + 2, midY, tailX + 40, midY);
g2.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
g2.setColor(PREV_COLOR);
g2.drawString("null", headX - 74, midY + 4);
g2.setColor(NEXT_COLOR);
g2.drawString("null", tailX + 44, midY + 4);
paintFirstLastTags(g2, CHAIN_LEFT, y, n);
for (int i = 0; i < n; i++) {
drawNode(g2, CHAIN_LEFT + i * CHAIN_PITCH, y, nodes.get(i), i);
}
paintNote(g2, y + NODE_H + 46,
"every box is a separate Node object somewhere in the heap; "
+ "the order exists only in the next/prev links");
paintLegend(g2, y + NODE_H + 68,
"navy arrow (upper) = next, forward | magenta arrow (lower) = prev, backward | "
+ "orange = node touched by the last call");
}
private void paintFirstLastTags(Graphics2D g2, int leftX, int y, int n) {
g2.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 11));
g2.setColor(NODE_COLOR);
if (n == 1) {
String tag = "first = last";
g2.drawString(tag, leftX + (NODE_W - g2.getFontMetrics().stringWidth(tag)) / 2, y - 20);
return;
}
String first = "first";
String last = "last";
FontMetrics fm = g2.getFontMetrics();
int firstCenter = leftX + NODE_W / 2;
int lastCenter = leftX + (n - 1) * CHAIN_PITCH + NODE_W / 2;
g2.drawString(first, firstCenter - fm.stringWidth(first) / 2, y - 20);
g2.drawString(last, lastCenter - fm.stringWidth(last) / 2, y - 20);
g2.setStroke(new BasicStroke(1.4f));
drawArrow(g2, firstCenter, y - 15, firstCenter, y - 3);
drawArrow(g2, lastCenter, y - 15, lastCenter, y - 3);
}
// ---------------- scattered layout ----------------
private void paintScattered(Graphics2D g2) {
int n = nodes.size();
int cols = SCATTER_COLS;
int rows = Math.max(MIN_SCATTER_ROWS, (n + cols - 1) / cols);
int total = cols * rows;
paintHeapGrid(g2, cols, rows);
int[] slots = assignSlots(n, total);
Rectangle[] boxes = new Rectangle[n];
for (int i = 0; i < n; i++) {
boxes[i] = boxInSlot(slots[i]);
}
// Links first, then the boxes on top of them.
// Only one direction at a time - drawing both doubles every line.
for (int i = 0; i < n - 1; i++) {
g2.setStroke(LINK_STROKE);
int bend = (i % 2 == 0) ? 30 : -30;
if (scatterShowPrev) {
g2.setColor(PREV_COLOR);
drawCurvedArrow(g2, boxes[i + 1], boxes[i], bend);
} else {
g2.setColor(NEXT_COLOR);
drawCurvedArrow(g2, boxes[i], boxes[i + 1], bend);
}
}
for (int i = 0; i < n; i++) {
drawNode(g2, boxes[i].x, boxes[i].y, nodes.get(i), i);
}
g2.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 10));
g2.setColor(NODE_COLOR);
labelUnder(g2, boxes[0], "first");
if (n > 1) {
labelUnder(g2, boxes[n - 1], "last");
}
// Null terminator at the end of the direction being shown:
// first.prev (magenta) in prev mode, last.next (navy) in next mode.
if (scatterShowPrev) {
paintNullStub(g2, boxes[0], true);
} else {
paintNullStub(g2, boxes[n - 1], false);
}
String linksShown = scatterShowPrev
? "magenta prev links (backward)"
: "navy next links (forward)";
paintNote(g2, rows * SLOT_H + 20,
"positions = identity hash (a pseudo \"address\" per node object), not real addresses; "
+ "only the " + linksShown + " are drawn here");
}
/** Short arrow ending in "null"; stays within the slot margin, so it never touches a neighbor box. */
private void paintNullStub(Graphics2D g2, Rectangle box, boolean leftSide) {
int y = box.y + box.height / 2;
int startX = leftSide ? box.x : box.x + box.width;
int endX = leftSide ? startX - 24 : startX + 24;
g2.setColor(leftSide ? PREV_COLOR : NEXT_COLOR);
g2.setStroke(NULL_STROKE);
drawArrow(g2, startX, y, endX, y);
g2.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
FontMetrics fm = g2.getFontMetrics();
g2.drawString("null", Math.max(2, endX - fm.stringWidth("null") / 2), y - 5);
}
private void paintHeapGrid(Graphics2D g2, int cols, int rows) {
g2.setColor(FAINT_COLOR);
g2.setStroke(new BasicStroke(1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
1f, new float[]{3f, 5f}, 0f));
for (int c = 1; c < cols; c++) {
int x = c * SLOT_W;
g2.drawLine(x, 0, x, rows * SLOT_H);
}
for (int r = 1; r < rows; r++) {
int y = r * SLOT_H;
g2.drawLine(0, y, cols * SLOT_W, y);
}
}
/** Deterministic slot per node: identity hash, with linear probing on collisions. */
private int[] assignSlots(int n, int total) {
boolean[] used = new boolean[total];
int[] slots = new int[n];
for (int i = 0; i < n; i++) {
int slot = Math.floorMod(nodes.get(i).id(), total);
while (used[slot]) {
slot = (slot + 1) % total;
}
used[slot] = true;
slots[i] = slot;
}
return slots;
}
private Rectangle boxInSlot(int slot) {
int col = slot % SCATTER_COLS;
int row = slot / SCATTER_COLS;
return new Rectangle(col * SLOT_W + (SLOT_W - NODE_W) / 2,
row * SLOT_H + (SLOT_H - NODE_H) / 2, NODE_W, NODE_H);
}
private void labelUnder(Graphics2D g2, Rectangle box, String text) {
FontMetrics fm = g2.getFontMetrics();
g2.drawString(text, box.x + (box.width - fm.stringWidth(text)) / 2, box.y + box.height + 15);
}
// ---------------- shared drawing ----------------
private void drawNode(Graphics2D g2, int x, int y, NodeView node, int index) {
boolean touched = index == lastTouchedIndex;
g2.setColor(NODE_COLOR);
g2.fillRoundRect(x, y, NODE_W, NODE_H, 12, 12);
if (touched) {
g2.setColor(TOUCHED_COLOR);
g2.setStroke(new BasicStroke(3f));
} else {
g2.setColor(OUTLINE_COLOR);
g2.setStroke(new BasicStroke(1f));
}
g2.drawRoundRect(x, y, NODE_W, NODE_H, 12, 12);
// item, vertically centered
g2.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
g2.setColor(Color.WHITE);
FontMetrics fm = g2.getFontMetrics();
String item = "\"" + node.item() + "\"";
g2.drawString(item, x + (NODE_W - fm.stringWidth(item)) / 2,
y + (NODE_H + fm.getAscent() - fm.getDescent()) / 2);
}
private void paintNote(Graphics2D g2, int y, String text) {
g2.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 11));
g2.setColor(new Color(105, 105, 105));
g2.drawString(text, 14, y);
}
private void paintLegend(Graphics2D g2, int y, String text) {
g2.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 10));
g2.setColor(new Color(140, 140, 140));
g2.drawString(text, 14, y);
}
private void drawArrow(Graphics2D g2, int x1, int y1, int x2, int y2) {
g2.drawLine(x1, y1, x2, y2);
fillArrowHead(g2, x1, y1, x2, y2);
}
private void drawCurvedArrow(Graphics2D g2, Rectangle from, Rectangle to, int bend) {
Point2D a = borderPoint(from, to.getCenterX(), to.getCenterY());
Point2D b = borderPoint(to, from.getCenterX(), from.getCenterY());
double mx = (a.getX() + b.getX()) / 2;
double my = (a.getY() + b.getY()) / 2;
double dx = b.getX() - a.getX();
double dy = b.getY() - a.getY();
double len = Math.max(1e-6, Math.hypot(dx, dy));
double cx = mx - dy / len * bend;
double cy = my + dx / len * bend;
g2.draw(new QuadCurve2D.Double(a.getX(), a.getY(), cx, cy, b.getX(), b.getY()));
fillArrowHead(g2, cx, cy, b.getX(), b.getY());
}
/** Point where the segment center -> (tx, ty) leaves the rectangle border. */
private Point2D borderPoint(Rectangle r, double tx, double ty) {
double cx = r.getCenterX();
double cy = r.getCenterY();
double dx = tx - cx;
double dy = ty - cy;
double sx = Math.abs(dx) < 1e-9 ? Double.MAX_VALUE : (r.getWidth() / 2.0) / Math.abs(dx);
double sy = Math.abs(dy) < 1e-9 ? Double.MAX_VALUE : (r.getHeight() / 2.0) / Math.abs(dy);
double s = Math.min(sx, sy);
return new Point2D.Double(cx + dx * s, cy + dy * s);
}
private void fillArrowHead(Graphics2D g2, double fromX, double fromY, double toX, double toY) {
double angle = Math.atan2(toY - fromY, toX - fromX);
double size = 9;
double left = angle - Math.toRadians(24);
double right = angle + Math.toRadians(24);
Path2D head = new Path2D.Double();
head.moveTo(toX, toY);
head.lineTo(toX - size * Math.cos(left), toY - size * Math.sin(left));
head.lineTo(toX - size * Math.cos(right), toY - size * Math.sin(right));
head.closePath();
g2.fill(head);
}
}
// ======================================================================
// Entry point
// ======================================================================
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
LinkedListBehaviorDemo demo;
try {
demo = new LinkedListBehaviorDemo();
} catch (InaccessibleObjectException e) {
JOptionPane.showMessageDialog(null,
"""
Reflection access to LinkedList internals was denied.
On JDK 9+ start the demo with:
java --add-opens=java.base/java.util=ALL-UNNAMED \\
LinkedListBehaviorDemo""",
"--add-opens required", JOptionPane.ERROR_MESSAGE);
return;
} catch (ReflectiveOperationException e) {
JOptionPane.showMessageDialog(null,
"Initialization failed: " + e.getMessage(),
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
demo.frame.setVisible(true);
demo.logGuidance();
});
}
/** Startup hints; repeated after Reset, so a restarted session reads the same. */
private void logGuidance() {
logLine("what to observe:");
logLine(" add / addFirst -> a new node is linked at the tail / head");
logLine(" add(i, ...) -> node(i) is walked first, then two neighbours are relinked");
logLine(" removeFirst/Last -> one end is unlinked, null termination moves");
logLine(" remove(i) -> same walk as add(i, ...), then the node is unlinked");
logLine(" \"Show heap scatter\" -> same list at pseudo heap positions:");
logLine(" neighbours in the list are not neighbours in memory");
logLine("");
}
}
LinkedList 的类图
如果忽略泛型信息,我们可以为 LinkedList 画出简要类图 ⬇️

如何为 java.util.LinkedList 绘制类图?
我使用了 Java 如何自动生成简单的 PlantUML 类图 一文中提供的代码。关键的命令如下
java
javac ClassDiagramGenerator.java
java ClassDiagramGenerator 'java.util.LinkedList'
第二个命令的输出如下
puml
@startuml
interface java.util.List
interface java.lang.Cloneable
interface java.lang.Iterable
interface java.util.Queue
interface java.util.SequencedCollection
interface java.io.Serializable
interface java.util.Deque
interface java.util.Collection
abstract java.util.AbstractCollection
abstract java.util.AbstractSequentialList
abstract java.util.AbstractList
class java.util.LinkedList
java.lang.Iterable <|-- java.util.Collection
java.util.Collection <|.. java.util.AbstractCollection
java.util.Collection <|-- java.util.SequencedCollection
java.util.SequencedCollection <|-- java.util.List
java.util.AbstractCollection <|-- java.util.AbstractList
java.util.List <|.. java.util.AbstractList
java.util.AbstractList <|-- java.util.AbstractSequentialList
java.util.Collection <|-- java.util.Queue
java.util.Queue <|-- java.util.Deque
java.util.SequencedCollection <|-- java.util.Deque
java.util.AbstractSequentialList <|-- java.util.LinkedList
java.util.List <|.. java.util.LinkedList
java.util.Deque <|.. java.util.LinkedList
java.lang.Cloneable <|.. java.util.LinkedList
java.io.Serializable <|.. java.util.LinkedList
@enduml
借助 IntelliJ IDEA (Community Edition) 中的 PlantUML 插件,就能将上述 puml 转化为类图。
参考资料
- Java 展示添加元素时 ArrayList 的内部变化
- Java 如何自动生成简单的 PlantUML 类图 (我绘制
java.util.LinkedList的类图参考了这篇文章)