Java CompactNumberFormat Bug or Feature?

Java 12 introduced a new CompactNumberFormat class, which anyone studying for the new Java 17 1Z0-829 OCP certification should know. It’s really cool utility feature, helping to shorten lengthy number values into shorter forms for common usage. It supports a Style setting, SHORT (1M) vs LONG (1 million), as well as rounding, and many other features. Generally speaking, it rounds the value to the first human-readable 3-digit tuple, formats it, and then adds a label depending on style/locale.

Let’s take a look at an example:

var shortCNF = NumberFormat.getCompactNumberInstance(Locale.US,
   Style.SHORT);
var longCNF = NumberFormat.getCompactNumberInstance(Locale.US,
   Style.LONG);
System.out.print(shortCNF.format(15_300));          // 15K
System.out.print(longCNF.format(15_300));           // 15 thousand
System.out.print(shortCNF.format(124_000_200));     // 124M
System.out.print(longCNF.format(124_000_200));      // 124 million
System.out.print(shortCNF.format(4_834_000_000.0)); // 5B
System.out.print(longCNF.format(4_834_000_000.0));  // 5 billion

Useful stuff, right? Notice the last two examples rounded the value up to 5 bllion? Rounding (which can be disabled) is enabled by default. Well, while writing some really tricky practice exam questions for our upcoming Java OCP 17 Practice Test Book, I discovered something rather odd:

var shortCNF = NumberFormat.getCompactNumberInstance(Locale.US,
   Style.SHORT);
var longCNF = NumberFormat.getCompactNumberInstance(Locale.US,
   Style.LONG);
System.out.print(shortCNF.format(999_999));   // 1000K (this is weird)
System.out.print(longCNF.format(999_999));    // 1000 thousand (this is weird)
System.out.print(shortCNF.format(1_000_000)); // 1M
System.out.print(longCNF.format(1_000_000));  // 1 million
System.out.print(shortCNF.format(1_999_999)); // 2M
System.out.print(longCNF.format(1_999_999));  // 2 million

Notice the issue? If the CompactNumberFormat rounds up and enters a new range (thousand to million, million to billion, etc), it doesn’t adjust the labels or values. The first two sets of values should print 1M and 1 million, but the rounded value prints 1000K and 1000 thousand instead. While I used Locale.US for reproducibility, this isn’t required. It appears when you use other locales, and other ranges. For instance, 999_999_999 formats as 1000M, instead of 1B. I validated on Oracle’s latest release of Java 17.0.2.

So.. is this a bug or a feature? It partially depends how you read the unicode spec the Java feature was based on. The spec covers formatting rules and order of operation, but it doesn’t provide as much insight on how rounding is supposed to be handled in this particular situation.

I believe this is a bug because:

  • No one would ever expect (or want) to see one million written as 1000 thousand or 1000K. If you saw that on a website or mobile app, you’d likely report it as a bug. (If you’re a developer using this feature, you would probably then be told to stop using this library altogether!)
  • If it is working as designed, then the spec has a problem. The only work-around for someone who wants to use CompactNumberFormat without encountering this issue is to either disable rounding, or round the value ahead of time. In either situation, the utility of using the CompactNumberFormat feature drops precipitously.

To me, it’s a bug…. or a feature that renders CompactNumberFormat not suitable for practical use. With that in mind, I opened a bug ticket JDK-8281317 with Oracle to address the issue. I will update this page when I get a response!

Side note: On February 6, I created a Twitter poll and interestingly enough the correct answer of 1000K was the least selected option! Certainly, not an intuitive implementation!

OCP 17 Book Bonus: Creating a HyperSQL Database in Java 17

Chapter 15 in our OCP Oracle Certified Professional Java SE 17 Study Guide: Exam 1Z0-829 uses the database setup described in this post. We are using HyperSQL because it supports SQL based stored procedures which are the most common form.

You don’t need to know anything about writing SQL or creating stored procedures for the exam. You do have to know how to call them from Java. This blog post is for readers who have purchased our book, OCP Oracle Certified Professional Java SE 17 Study Guide: Exam 1Z0-829 and want to follow along. It also includes the database installation instructions and setup code so you can simply copy/paste it. The actual book covers what you need to know for the exam.

Download HSQL

Option 1: Download the latest version of hsqldb.jar from Maven Central. The jar is about 1.5 MB.

Option 2: HSQL is an open source database. It is really easy to install and use. To install:

  1. Go to the HSQL home page.
  2. Click on the link for the latest version. (At the time of this blog post, that was 2.6.0)
  3. Click through the many screens until it downloads.
  4. Download the zip file and unzip it. This will create a folder named something like hsqldb-2.6.0. Drill down to hsqldb and then lib to get the hsqldb.jar file.

Copy this hsqldb.jar to someplace convenient on your machine.

Creating your initial database

To start out, copy this code into a file named SetupDatabase.java.

import java.sql.*;

public class SetupDatabase {

    public static void main(String[] args) throws Exception {
        String url = "jdbc:hsqldb:file:zoo";
        try (Connection conn = DriverManager.getConnection(url);
             Statement stmt = conn.createStatement()) {

            dropExisting(conn);
            createTables(conn);
            createStoredProcedures(conn);

            printCount(conn, "SELECT count(*) FROM names");
        }
    }

    private static void dropExisting(Connection conn) throws SQLException {
        run(conn, "DROP PROCEDURE read_e_names IF EXISTS");
        run(conn, "DROP PROCEDURE read_names_by_letter IF EXISTS");
        run(conn, "DROP PROCEDURE magic_number IF EXISTS");
        run(conn, "DROP PROCEDURE double_number IF EXISTS");
        run(conn, "DROP TABLE names IF EXISTS");
        run(conn, "DROP TABLE exhibits IF EXISTS");
    }

    private static void createTables(Connection conn) throws SQLException {
        run(conn, """
                CREATE TABLE exhibits (
                  id INTEGER PRIMARY KEY,
                  name VARCHAR(255),
                  num_acres DECIMAL(4,1))""");

        run(conn, """
                CREATE TABLE names (
                   id INTEGER PRIMARY KEY,
                   species_id integer REFERENCES exhibits (id),
                   name VARCHAR(255))""");

        run(conn, "INSERT INTO exhibits VALUES (1, 'African Elephant', 7.5)");
        run(conn, "INSERT INTO exhibits VALUES (2, 'Zebra', 1.2)");

        run(conn, "INSERT INTO names VALUES (1, 1, 'Elsa')");
        run(conn, "INSERT INTO names VALUES (2, 2, 'Zelda')");
        run(conn, "INSERT INTO names VALUES (3, 1, 'Ester')");
        run(conn, "INSERT INTO names VALUES (4, 1, 'Eddie')");
        run(conn, "INSERT INTO names VALUES (5, 2, 'Zoe')");
    }

    private static void createStoredProcedures(Connection conn) throws SQLException {
        String noParams = """
                CREATE PROCEDURE read_e_names()
                READS SQL DATA DYNAMIC RESULT SETS 1
                BEGIN ATOMIC
                DECLARE result CURSOR WITH RETURN FOR SELECT * FROM names WHERE LOWER(name) LIKE 'e%';
                OPEN result;
                END""";

        String inParam = """
                CREATE PROCEDURE read_names_by_letter(IN prefix VARCHAR(10))
                READS SQL DATA DYNAMIC RESULT SETS 1
                BEGIN ATOMIC
                DECLARE result CURSOR WITH RETURN FOR
                SELECT * FROM names WHERE LOWER(name) LIKE CONCAT(LOWER(prefix), '%');
                OPEN result;
                END""";

        String inOutParam = """
                CREATE PROCEDURE double_number(INOUT num INT) READS SQL DATA
                  DYNAMIC RESULT SETS 1 
                  BEGIN ATOMIC 
                  SET num = num * 2; 
                  END""";

        String outParam = """
                CREATE PROCEDURE magic_number(OUT num INT) READS SQL DATA
                      BEGIN ATOMIC
                     SET num = 42;
                      END""";

        run(conn, noParams);
        run(conn, inParam);
        run(conn, outParam);
        run(conn, inOutParam);
    }

    private static void run(Connection conn, String sql) throws SQLException {
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.executeUpdate();
        }
    }

    private static void printCount(Connection conn, String sql) throws SQLException {
        try (PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
            rs.next();
            System.out.println(rs.getInt(1));
        }
    }
}


To run the program, you need to include the hsqldb jar file in your classpath. If you don’t know how to find it or encounter problems see the below frequently asked questions in this blog post.

java -cp "<path to jar>/hsqldb.jar" SetupDatabase.java

If all goes well, the program will output the number 5.

Note that we used single file source code execution here. This simplified the classpath since we only needed to specify the derby jar. Had we compiled our class we would have needed to include it as well (along with an operating system delimiter)

Alternatively, you could have added HSQL to your CLASSPATH environment variable and just run the program as

java SetupDatabase.java

What does this program actually do?

The main method starts out by obtaining a connection to the HSQL database. It then calls a number of run methods to actually run the SQL. The run methods use a PreparedStatement with all the data hardcoded.

First the code drops anything already in the database. This allows you to run the program over and over receiving the same results. Then, the code runs two SQL statements to create tables in the zoo database. The commands each include:

  • the table name – exhibits and names
  • the fields in each table along with their type. Integer is like a Java int. Decimal is like a Java double. Varchar stands for variable character and is like a String. The variable length part means that the database doesn’t need to allocate space for all 255 characters and should only use the space for the actual length of the string. (This matters when you frequently update the field with values of different lengths)
  • the primary key for each table – this tells the database how to you uniquely identify each row

Next, the code runs seven SQL statements to insert rows into these tables. The order of the data columns matches the order the fields were defined in the create statements. After that, it creates the stored procedures used in the book.

Finally, the code runs a query to check the rows were added to the database. The count(*) function in SQL always returns a number. For an empty table, this number is zero. Therefore, we can call rs.next() outside of a conditional or loop. We know there is always a number being returned.

HSQL will create some “zoo” files in your current directory for the database and logs.

Frequently Encountered Problems

If you have an error that isn’t here or have trouble with these instructions, feel free to ask a question in the CodeRanch forums

What does “user lacks privilege or object not found in statement” mean?

In HSQL, it means “something went wrong.” It could be a runtime error in your stored proc. It could be that you aren’t calling it right. Confusing message, I know.

What does “no suitable driver” found mean?

Exact message:

Exception in thread "main" java.sql.SQLException: No suitable driver found for jdbc:hsqldb:file:zoo
	at java.sql/java.sql.DriverManager.getConnection(DriverManager.java:706)
	at java.sql/java.sql.DriverManager.getConnection(DriverManager.java:252)
	at SetupDatabase.main(SetupDatabase.java:7)

This means you forgot to include the hsqldb.jar in your classpath or are pointing to an invalid location for it.

Jeanne’s 1Z0-829 experience

I passed the 1Z0-829 today. The exam page still says “coming soon” and Scott didn’t see the 829 in the list of choices when he looked last night. I think the exam “wasn’t quite ready” and they pulled it back. (or accidentally released it too early). I guess I got to take it because I was registered already. I saw 5-6 errata on the exam which I have reported to Oracle.

COVID logistics

When I took the 819, I was asked to remove my mask twice – once for an ID check and once for a photo. This time, those steps were combined, so I only had to remove my mask once.

I hadn’t been to this test center before. It was walking distance and well set up. I was a good distance from any other test takers. The only problem I had was that I was right next to the radiator. It would have been hot even without a mask on. With a mask, I was sweating.

Other logistics

I was given a marker and since sheet to write on. I was not given an eraser. I asked for a second sheet and was given it though. (I write a lot.) The man in front of me asked for a second marker because it had run out once. They were flexible.

Some time ago, you could right click answers to cross them out of consideration. This feature is not yet back.

Time management

My first pass of the exam took 65 minutes. I then spent a good while examining and memorizing the questions that I believe to have errata. I didn’t get a chance to go through and sanity check my answers because I was tracking errata. If I wasn’t a cert book author, I’d have focused on review and gotten a higher score.

Getting the score

I got my score right when I submitted. (68%). I didn’t get a printout. But I didn’t need one since I had seen the score. My details were available on certview as soon as I got home as well.

You might notice the passing score is also 68%. Why so low you ask? A few reasons

  • Some of the errata resulted in having to guess at the answer. For example a question saying to pick two correct answers had three correct answers. So i had to guess what the exam creators meant. (I have reported all of these to Oracle, there were a bunch)
  • I didn’t check my answers because I was dealing with the above.
  • A few questions were things I didn’t expect to be in scope. (They will be covered in the book.) This is one of the disadvantages of being a cert book author – you have to take the exam without a study guide.)

I felt way more confident about this exam than I did the 819 though. I like the question distribution better and I didn’t have a COVID lockdown cloud hanging over me.

Question Distribution

When taking an exam, you have to agree not to share what was on it. So no details about what was covered. Sharing the distribution of questions by objective is fair game though!

Note that this is approximate because of relying on memory. And also because some questions spanned objectives

Objective# Questions
Handing date, time, text, numeric and boolean valuesLots
Controlling Program FlowLots
Utilizing Java Object-Oriented ApproachLots
Handling Exceptions3-5
Working with Arrays and Collections4-6
Working with Streams and Lambda expressions4-6
Package and deploy Java code and use the Java Platform Module System4
Manage concurrent code execution4
Use Java I/O API4
Access databases using JDBC2
Implement Localization2

An important disclaimer about randomness

With only 50 questions, randomness is a bigger factor. This means you could easily not see questions on a topic. Or get more than someone else on another topic. Be careful as you read the experiences of people who have taken the exam. Just because they didn’t get a question on X doesn’t mean that you won’t! So you don’t get to skip studying topics.