loading...
Cover image for Understanding the Java Classpath: Building a Project Manually

Understanding the Java Classpath: Building a Project Manually

martingaston profile image Martin Gaston ・7 min read

This is the second post in a series looking to understand Java projects. Earlier, we looked at how Java actually gets installed onto a macOS system, and now we're going to build a basic app. Then we'll move on to incorporating the same app with help from Gradle, a popular build tool, and we'll finish by incorporating our project into the IntelliJ IDE.

Our directory structure

We're going to be compiling a totally jazzed up version of Hello World, and our overblown example will make use of packages, libraries and tests because it's a more indicative of a project we'd spot in the wild. This project is intended for people who have worked a little with Java and isn't going to dwell much on syntax outside of tooling.

We'll be loosely following the conventions of the Maven standard directory layout, which is admittedly a little overkill for our tiny app - but we're also going to charge ahead with it because it's indicative of the conventions we'll commonly run into when working with Java. Here's how that looks:

.
├── bin
├── lib
│   ├── jfiglet-0.0.8.jar
│   └── junit-platform-console-standalone-1.4.2.jar
└── src
    ├── main
    │   └── java
    │       └── space
    │           └── gaston
    │               ├── greeter
    │               │   └── Greeter.java
    │               ├── helloWorld
    │               │   └── HelloWorld.java
    │               └── textEffects
    │                   └── Bubble.java
    └── test
        └── java
            └── space
                └── gaston
                    ├── greeter
                    │   └── GreeterTest.java
                    └── textEffects
                        └── BubbleTest.java

Let's unpack what we've got here:

  • bin will contain our compiled .class files
  • lib will hold our third-party libraries. In this case we're using JFiglet and the JUnit test runner, which we'll get back to later.
  • src will house our .java source code. Within src you have subdirectories main and test , which both have a java subdirectory to denote the language, and then the standard Java package hierarchy. Eventually, after months of digging, you finally stumble upon our actual .java files.

Just a quick caveat: I haven't checked the lib folder into Git so you'll need to grab the JFiglet and JUnit .jar files from Maven if you're looking to build this project yourself. This is, partially, to expose how fiddly it is to manage dependencies manually and help us understand how, later on, how awesome it is that other tools can come to our rescue.

.java, .class and .jar

Okay, so I threw around quite a few filetypes just then. I think it's entirely common to be hidden from a lot of these specifics by our IDE, so here's a quick summary of what each of those types does:

  • A .java file is a plain-text file of human-readable Java source code - though admittedly that 'readable' designation is, ahem, rather debatable when it comes to certain aspects of Java syntax. Basically, .java files are the files we squishy human flesh monsters sling our code in.
  • A .class file is compiled Java bytecode that can be executed by the Java Virtual Machine, an admittedly snazzy tool which runs our Java programs. These cold, mechanical files are the ones the computer likes read.
  • A .jar file is an archive of .class files (and other necessary resources) all neatly zipped up for convenience.

javac and java

Two of our JDK binaries are responsible for compiling and running our code: javac and java. In short, javac is responsible for turning our .java files into .class files that java can run.

If we put together a totally barebones HelloWorld.java, we can then feed it as an argument to javac and run it with java:

public class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Oh no, not another Hello World example...");
  }
}

Without multiple .java files, external libraries or output directories to worry about, compiling and running this is as straightforward as sending the file path to javac and then running the output:

$ javac HelloWorld.java
$ java HelloWorld
Oh no, not another Hello World example...

Notice how the .class file will be currently be compiled into the same directory as its companion source code, and that we call the HelloWorld class directly with java with no .class extension - the latter would trigger a dreaded java.lang.ClassNotFoundException error, but that's a story for another day. It turns out we use java to run Java classes, not Java .class files.

Classpath

Another potential error we might see right about now is a java.lang.NoClassDefFoundError, which usually comes about because there's a .class file that was created at compile time (with javac) but has got lost somewhere when we're trying to run it with java.

Be warned to those of a fragile constitution, the next word is enough to give you the vapours. This frightful concept has dashed the hopes and dreams of event the strongest minds, adventurer. Abandon all hope all ye who enter... the classpath.

Let's zoom in our our main .java files:

.
├── greeter
│   └── Greeter.java
├── helloWorld
│   └── HelloWorld.java
└── textEffects
    └── Bubble.java

Our Greeter.java, HelloWorld.java and Bubble.java are all being stored in different packages (which, in Java, also means different directories) and have requirements of their own. HelloWorld includes both a Greeter and a Bubble, with Bubble itself an adapter to the third-party FigletFont class from the JFiglet .jar in our lib folder.

Over in our test folder we've got tests for Greeter and Bubble, which includes classes from JUnit in lib as well as requiring the actual Greeter and Bubble classes to test.

Welcome, friends, to dependencies.

Java, while pretty smart, needs to know where to go sniffing for all these requirements - hence the classpath. We can get the scoop right from the horse's mouth:

The default value of the class path is ".", meaning that only the current directory is searched. Specifying either the CLASSPATH variable or the -cp command line switch overrides this value.

Also:

Setting the CLASSPATH can be tricky and should be performed with care.

Which is just charming. Essentially, our classpath needs to contain the path to our .jar files to the top of our package hierarchies. It can be set either via environment variable, which you shouldn't do, or with the much better option of the -cp flag.

It also helps explain a common Java gotcha, where you try and run java without cd'ing into the directory first.

$ java bin.space.gaston.helloworld.HelloWorld
Error: Could not find or load main class bin.space.gaston.helloworld.HelloWorld
Caused by: java.lang.NoClassDefFoundError: space/gaston/helloworld/HelloWorld (wrong name: bin/space/gaston/helloworld/HelloWorld)

bin, you see, isn't part of the Java package hierarchy, but does need to be on the classpath if you're outside of the folder. So both of the below would work:

$ cd bin
$ java space.gaston.helloworld.HelloWorld
$ java -cp bin space.gaston.helloworld.HelloWorld

Bringing it all together

With all that in mind, we can start to compile our files into the bin directory. From the top of our project directory, we can get to work.

1. Compiling our main folder:

$ javac -d bin -cp lib/jfiglet-0.0.8.jar src/main/java/space/gaston/greeter/Greeter.java src/main/java/space/gaston/helloWorld/HelloWorld.java src/main/java/space/gaston/textEffects/Bubble.java

We're now using the -d flag to specify where our compiled files should end up. We're manually feeding each of our .java files to javac, so we don't need to add them to the classpath, but we do need to add our JFiglet .jar file so that Bubble can compile.

2. Compiling our test folder:

$ javac -d bin -cp lib/junit-platform-console-standalone-1.4.2.jar:lib/jfiglet-0.0.8.jar src/main/java/space/gaston/textEffects/Bubble.java src/test/java/space/gaston/textEffects/BubbleTest.java src/main/java/space/gaston/greeter/Greeter.java src/test/java/space/gaston/greeter/GreeterTest.java

We need to add both the JFiglet and JUnit .jar files to our classpath, and now we've also got to feed in each test file and the file its testing to the compiler. We could effectively consolidate steps 1 and 2, but it's good to break them up for demonstration purposes here as I think it helps illustrate what's going on.

Our bin file will now look like this - notice that the directory structure of our .class files must maintain the same package hierarchy as the .java source files:

.
├── BubbleTests.class
├── GreeterTests.class
└── space
    └── gaston
        ├── greeter
        │   └── Greeter.class
        ├── helloworld
        │   └── HelloWorld.class
        └── textEffects
            └── Bubble.class

3. Running our tests:

$ java -jar lib/junit-platform-console-standalone-1.4.2.jar -cp bin:lib/jfiglet-0.0.8.jar --scan-class-path
╷
├─ JUnit Jupiter ✔
│  ├─ BubbleTests ✔
│  │  └─ helloReturnsAsciiHello() ✔
│  └─ GreeterTests ✔
│     ├─ greetWithArgumentSteveReturnsHelloSteve() ✔
│     └─ greetWithNoArgsReturnsHelloWorld() ✔
└─ JUnit Vintage ✔

Our version of JUnit can be run on the command line. We're passing in the --scan-class-path flag to automatically have JUnit look for all tests on our classpath, so this requires adding the bin folder to the classpath (because we're at the top of our project folder) as well as JFiglet, which is still required by Bubble.

Also, yay, the tests pass.

4. Running our main app:

$ java -cp bin:lib/jfiglet-0.0.8.jar space.gaston.helloworld.HelloWorld
  _   _          _   _             ____    _
 | | | |   ___  | | | |   ___     / ___|  | |_    ___  __   __   ___
 | |_| |  / _ \ | | | |  / _ \    \___ \  | __|  / _ \ \ \ / /  / _ \
 |  _  | |  __/ | | | | | (_) |    ___) | | |_  |  __/  \ V /  |  __/
 |_| |_|  \___| |_| |_|  \___/    |____/   \__|  \___|   \_/    \___|

No, I have no idea either why we tasked it to output 'Hello Steve'.

So, great. We've got that working. But, gosh, wasn't that a bit much for even a simple, totally contrived application? Can you imagine that literally every time you make a change to the app and need to recompile? I don't know about you, but if I had to work like that for over a week I'd be permanently stuck looking like I was cosplaying Edvard Munch's The Scream.

In the next post, the cavalry will march in to bail us out of a lifetime of perpetual build horror.


Has this post been useful for you? I'd really appreciate any comments and feedback on whether there's anything that could be made clearer or explained better. I'd also massively appreciate any corrections!

Posted on by:

martingaston profile

Martin Gaston

@martingaston

Polyglot developer taking the midnight train going anywhere

Discussion

pic
Editor guide
 

Yes, very helpful to see things laid out on the CLI in glorious, gory detail! It does a lot to enhance understanding.

I have a couple minor quibbles/corrections for you to consider. The first is that a .jar file is not restricted to holding .class files, but can also hold source and/or resources. True, a build is going to reference the jar almost without exception for the class files it holds. I did say this was a quibble.

Second, I stumbled a bit over the section "java and javac". I think I might have been less confused if the wording "If we put together a totally barebones HelloWorld.java" were changed to "If we put together the following totally barebones HelloWorld.java". That way, I'm more likely to figure out that the following is a new, ad hoc code example, and not some portion of the project. (I am slow to pick stuff like this up, and can use all the help a writer is willing to give.)

A couple other spots got me, but I figured things out. Overall very helpful article. Thank you!

 

Great article. Would keep it in my bookmarks for reference...

 

Very helpful article. Thank you.
Waiting for your following article about how the build tool like Gradle and Maven could make the compile and run process less painful