User Guide
What is LJV?
LJV is a tool for visualizing Java data structures, using Reflection API and Graphviz. Its original version was developed by John Hamer in 2004 and released under GNU GPL (see the original project page).
This project aims to upgrade this tool to modern Java and make it an open source library in the modern sense of the word.
Getting started
The tool requires Java 11 or later version. You can use Maven or Gradle to import the LJV dependency:
<dependency>
<groupId>org.atp-fivt</groupId>
<artifactId>ljv</artifactId>
<version>1.03</version>
</dependency>
import org.atpfivt.ljv.LJV;
...
System.out.println(new LJV().drawGraph("Hello"));
The printed result will look like following:
digraph Java {
rankdir="TB";
node[shape=plaintext]
. . .
}
This is a graph definition in DOT graph description language. This script can be converted into a bitmap or vector image using dot
utility from graphviz
package:
dot -Tpng myfile.dot > myfile.png
Or, even simpler, the resulting script can be pasted to GraphViz Online and visualized like this:
Or, even more simpler, you may use the following utility function in order to save some tedious copy-pasting:
public static void browse(LJV ljv, Object obj) {
try {
var dot = URLEncoder.encode(
ljv.drawGraph(obj), "UTF8").replaceAll("\\+", "%20");
Desktop.getDesktop().browse(
new URI("https://dreampuf.github.io/GraphvizOnline/#"
+ dot));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
The diagram can be interpreted as following:
-
The
String
object that we are visualizing is represented as a 'root' node of the graph -
This object has two fields of primitive types (
coder
andhash
), with values set to zero. -
This object through the field
value
references another object of an array type. This array has 5 elements. -
Elements of the array are primitive values 72, 101, 108, 108 and 111.
Diagram customization
LJV has the ability to customize the view of the graph.
For example, you can specify any class to be treated as a primitive type with setTreatAsPrimitive
method. In this case, the result of toString
call will be used as its value:
String graph = new LJV()
.setTreatAsPrimitive(String.class)
.drawGraph(
new Object[]{new String[]{"a", "b", "c"}, new int[]{1, 2, 3}}
);
You can also change the drawing direction:
ArrayList<Object> a = new ArrayList<>();
a.add(new Person("Albert", true, 35));
a.add(new Person("Betty", false, 20));
a.add(new java.awt.Point(100, -100));
String graph = new LJV()
.setTreatAsPrimitive(String.class)
.setDirection(Direction.LR)
.drawGraph(a);
Direction
is an enum
with four values that define the graph drawing direction (top to bottom, left to right, etc):
It is also possible to set colors and styles for the components of the graph:
Node n1 = new Node("A");
n1.level = 1;
AnotherNode n2 = new AnotherNode("B");
n2.level = 2;
AnotherNode n3 = new AnotherNode("C");
n3.level = 2;
n1.left = n2;
n1.right = n3;
n1.right.left = n1;
n1.right.right = n1;
String graph = new LJV()
.addFieldAttribute("left", "color=red,fontcolor=red")
.addFieldAttribute("right", "color=blue,fontcolor=blue")
.addClassAttribute(Node.class, "color=pink,style=filled")
.addIgnoreField("level")
.addIgnoreField("ok")
.setTreatAsPrimitive(String.class)
.setShowFieldNamesInLabels(false)
.drawGraph(n1);
Usage
java.util.String before and after Java 9
The most widely used type of data in Java is, of course, String
.
Starting from Java 9, the internal representation of String
has changed: char[]
was replaced by byte[]
, and coder
flag was introduced in order to switch between 8-bit and 16-bit character representation. This allowed significant memory optimization for strings that contain only LATIN-1 charset characters:
|
|
|
String creation
One rarely needs to create String
using new
operator, however it’s worth noticing that String(String original)
constructor reuses the internal byte array of its argument.
Concatenation (even with an empty string!) always produces a full new copy:
String x = "Hello";
new LJV().drawGraph(new String[]{
x, new String(x),
new String(x.toCharArray()),
x + ""});
String interning
Calling intern()
deduplicates all the String
objects and reduce them to a single value kept in the String
pool (compare with the previous example):
String x = "Hello";
new LJV().drawGraph(new String[]{
x, new String(x).intern(),
new String(x.toCharArray()).intern(),
(x + "").intern()}));
Boxed primitives caching
Usually we create boxed primitives via autoboxing. In rare cases when we do need to create e. g. Integer
object explicitly, the correct way to do this is with Integer.valueOf
method. This method deduplicates values in the range from -128 to 127 or -XX:AutoBoxCacheMax
value.
Values outside this range will not be deduplicated even when autoboxing is used.
Integer
created with constructor will never be deduplicated, and this constructor is deprecated since Java 9.
new LJV().drawGraph(new Integer[]{
42, Integer.valueOf(42),
new Integer(42),
-4242, -4242
});
LinkedList
Linked list is a data structure with theoretical O(1) efficiency for adding/removing its random node that can acts both as List
and Deque
. In Java practice, however, LinkedList
is superceded by ArrayList
and ArrayDeque
in all the cases, and it’s questionable whether this class is needed in standard library at all.
List<Integer> list = new LinkedList<>();
list.add(1); list.add(42); list.add(21);
new LJV()
.setTreatAsPrimitive(Integer.class)
.setDirection(Direction.LR)
.drawGraph(list);
ArrayDeque
If not LinkedList
, then what? Java has a number of high-performant array-based data structures. ArrayList
is well-known, but there are also ArrayDeque
based on looped array and PriorityQueue
based on balanced binary heap, which is actually also an array.
Let’s see, for example, how looped buffer of ArrayDeque
works.
This structure implements queue capabilities. If maximum number of elements in the queue does not grow over time, this data structure works very fast and memory efficient, with constant time for every operation.
LJV ljv = new LJV().setTreatAsPrimitive(Integer.class);
//note that this sets initial capacity to 5
Deque<Integer> arrayDeque = new ArrayDeque<>(4);
arrayDeque.add(1); arrayDeque.add(2); arrayDeque.add(3);
ljv.drawGraph(arrayDeque)
arrayDeque.poll(); //returns 1
arrayDeque.poll(); //returns 2
ljv.drawGraph(arrayDeque);
Here we reach the end of the buffer and start writing from the beginning:
arrayDeque.add(4); arrayDeque.add(5); arrayDeque.add(6);
ljv.drawGraph(arrayDeque);
HashMap
HashMap
is a widely used data structure in Java. For many people, implementation details of HashMap
is also a favorite topic of discussion in a Java programmer job interview.
There are a number of ways to implement hash collisions resolution in a hash map, developers of Java platform chose linked lists:
Map<String, Integer> map = new HashMap<>();
map.put("one", 1); map.put("two", 2);
map.put("three", 3); map.put("four", 4);
new LJV()
.setTreatAsPrimitive(Integer.class)
.setTreatAsPrimitive(String.class)
.drawGraph(map);
Collision
While the number of collisions on a single HashMap
bucket is small, the linked list keeps growing:
List<String> collisionString = new HashCodeCollision().genCollisionString(3);
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < collisionString.size(); i++) {
map.put(collisionString.get(i), i);
}
new LJV()
.setDirection(Direction.LR)
.setTreatAsPrimitive(Integer.class)
.setTreatAsPrimitive(String.class)
.setIgnoreNullValuedFields(true)
.drawGraph(map);
'Treeified' collision
However, if a single bucket becomes overloaded with collisions, and keys implement Comparable
interface, the linked list turns to a tree.
This reduces the search time in a bucket from O(N) to O(log(N)) and mitigates a certain kind of DDoS attacks:
List<String> collisionString = new HashCodeCollision().genCollisionString(6);
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < collisionString.size(); i++) {
map.put(collisionString.get(i), i);
}
String graph = new LJV()
.setTreatAsPrimitive(String.class)
.setTreatAsPrimitive(Integer.class)
.setIgnoreNullValuedFields(true)
.drawGraph(map);
LinkedHashMap
One of the features of HashMap
is that this data structure completely 'forgets' the order of insertion of its elements.
Also, iteration over HashMap
is not very effective from performance point of view.
When insertion order matters, we can use LinkedHashMap
, which is actually a HashMap
combined with linked list.
One of the possible use cases for LinkedHashMap
is LRU cache implementation.
Map<String, Integer> map = new HashMap<>();
map.put("one", 1); map.put("two", 2);
map.put("three", 3); map.put("four", 4);
new LJV().setDirection(LR)
.setTreatAsPrimitive(Integer.class)
.setTreatAsPrimitive(String.class)
.drawGraph(map);
TreeMap
TreeMap
in Java is a Red-Black tree that implements NavigableMap
. This implementation provides guaranteed O(log(N)) time cost for get/put/remove operations, which in practice is inferior to HashMap
.
We use TreeMap
when we need lowerKey(..)
, higherKey(..)
and other NavigableMap
capabilities not provided by a simple Map
.
Map<String, Integer> map = new TreeMap<>();
map.put("one", 1); map.put("two", 2);
map.put("three", 3); map.put("four", 4);
new LJV().setDirection(LR)
.setTreatAsPrimitive(Integer.class)
.setTreatAsPrimitive(String.class)
.setIgnoreNullValuedFields(true)
.drawGraph(map);
ConcurrentSkipListMap
ConcurrentSkipListMap
is a thread-safe NavigableMap
implementation, that uses quite a complex non-blocking algorithm involving random numbers generator. That’s why for a given input its internal representation never looks the same from one run to another:
ConcurrentSkipListMap<String, Integer> map = new ConcurrentSkipListMap<>();
map.put("one", 1);
map.put("two", 2);
map.put("three", 3);
map.put("four", 4);
String actualGraph = new LJV()
.setTreatAsPrimitive(Integer.class)
.setTreatAsPrimitive(String.class)
.drawGraph(map);