Java Date serialization: Why you shouldn't bother trying

This is a short piece about a Java pitfall I just stumbled across. I should have run into this >25 years ago so maybe it's ancient news.

Quite a while ago I wrote a little password generator. I find it kind of useful. I'm aware of commercial password managers, and I'm aware of commercial password managers that experienced massive data breaches. If someone is storing your passwords there's always a chance a bad guy will gain access to them. So I wrote an application that doesn't store passwords.

The UI could be nicer though. For example, I'd like the list of password settings on the left to sort by last used.


NARpassword (JavaFX version)

That's pretty simple right? Behind each entry on the list is a serializable Java object creatively called PasswordSetting. It's super easy, just add a Date variable of course.

Everything was great until I updated unit tests. I have one test for JSON serialization. I use Google's gson library because it's easy and I don't want to write something similar myself. Maybe I do now though. The unit test goes like:


[... build a list of passwords ...]
String json=new Gson().toJson(toList);
//read it back
ArrayList<PasswordSetting> fromList=new Gson().fromJson(json,new TypeToken<List<PasswordSetting>>(){}.getType());
[... compare the list read to the original one ...]

My unit test failed when comparing the newly added Date variable. You can try this yourself with a little code like:


Date now=(Calendar.getInstance()).getTime();
String json=new Gson().toJson(now);
Date jnow=new Gson().fromJson(json,Date.class);
System.out.println("now.equals(jnow):"+(now.equals(jnow)));
System.out.println("now.compareTo(jnow):"+(now.compareTo(jnow)));
System.out.println("now.getTime()==jnow.getTime():"+(now.getTime()==jnow.getTime()));
System.out.println("now.getTime():"+now.getTime());
System.out.println("jnow.getTime():"+jnow.getTime());

That will produce a result similar to:


now.equals(jnow):false
now.compareTo(jnow):1
now.getTime()==jnow.getTime():false
now.getTime():1716073433965
jnow.getTime():1716073433000

OK, gson is discarding milliseconds when storing or retrieving Date objects.

I could have saved myself a few minutes by reading the Gson code comments:


   *   <li>The default Date format is same as {@link java.text.DateFormat#DEFAULT}. This format
   *       ignores the millisecond portion of the date during serialization. You can change this by
   *       invoking {@link GsonBuilder#setDateFormat(int, int)} or {@link
   *       GsonBuilder#setDateFormat(String)}.

Java contains built-in classes for serialization. They're very handy but store everything in a binary format that is only usable by other Java applications. Using JSON means the settings could be read/written by a UI in a completely different technology. That was a goal whenever I started this project.

Let's see how that handles Date objects just for fun:


FileOutputStream fout=new FileOutputStream("whatever.txt");
ObjectOutputStream obout=new ObjectOutputStream(fout);
obout.writeObject(now);
obout.flush();
obout.close();
FileInputStream fin=new FileInputStream("whatever.txt");
ObjectInputStream obin=new ObjectInputStream(fin);
Date onow=(Date)obin.readObject();
obin.close(); 
System.out.println("now.equals(onow):"+(now.equals(onow)));
System.out.println("now.compareTo(onow):"+(now.compareTo(onow)));
System.out.println("now.getTime()==onow.getTime():"+(now.getTime()==onow.getTime()));
System.out.println("now.getTime():"+now.getTime());
System.out.println("onow.getTime():"+onow.getTime());

That produces an expected result:


now.equals(onow):true
now.compareTo(onow):0
now.getTime()==onow.getTime():true
now.getTime():1716073433965
onow.getTime():1716073433965

Let's take a look at some snippets of the Java Date class. Go here for the full file. The giant header is included because of the menacing all-caps directive:


/*
 * Copyright (c) 1994, 2016, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */
[...]
public class Date implements java.io.Serializable, Cloneable, Comparable
{
    private static final BaseCalendar gcal = CalendarSystem.getGregorianCalendar();
    private static BaseCalendar jcal;
    private transient long fastTime;
[...]
    public Date() {
        this(System.currentTimeMillis());
    }
[...]
    public Date(long date) {
        fastTime = date;
    }
[...]
    public long getTime() {
        return getTimeImpl();
    }
    private final long getTimeImpl() {
        if (cdate != null && !cdate.isNormalized()) {
            normalize();
        }
        return fastTime;
    }
[... note - not including code for normalize() because it makes no difference ...]
    /**
     * Save the state of this object to a stream (i.e., serialize it).
     *
     * @serialData The value returned by {@code getTime()}
     *             is emitted (long).  This represents the offset from
     *             January 1, 1970, 00:00:00 GMT in milliseconds.
     */
    private void writeObject(ObjectOutputStream s) throws IOException
    {
        s.defaultWriteObject();
        s.writeLong(getTimeImpl());
    }
    /**
     * Reconstitute this object from a stream (i.e., deserialize it).
     */
    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException
    {
        s.defaultReadObject();
        fastTime = s.readLong();
    }
[...]
}

Do you see it?

At the end of the day, Date is a simply a wrapper for a long value. That long value happens to be Unix epoch time. It is stored in a variable called fastTime. Date objects are serializable. Despite fastTime being literally the only value that matters in the class it is marked transient making it not serializable. Instead, Date overrides writeObject & readObject to... write & read only the fastTime variable.

The original authors of Java are certainly smarter than me, but that's not a high bar. This approach strikes me as how it would appear in a design pattern book rather than the most simple solution. What do I know though? I have two programming books on my desk right now. One is a >1200 page book of algorithms and the other is a 68000 language reference. My "Gang of Four" design pattern book has long been relegated to bottom shelf status.

If I was interviewing Java developers and asked "How do you make a field serializable?" and received responses like:

Candidate A: "Make the class serializable and let the Java runtime handle the details."

Candidate B: "Make the class serializable then mark the field transient and override writeObject & readObject to explicitly serialize the field."

I would hire Candidate A. They're going to get a lot done. Candidate B will argue with everyone all the time and try to rewrite the entire system from scratch so it's done the "right way". Look, design patterns are a fine reference but most of your programming career is spent on systems that already perform some desired function. Your job is to keep it that way while adding new features and fixing bugs.

As for the original problem I was trying to solve... I'm just going to use a long instead of a Date to store what I need. It's smaller, more portable, and can trivially be converted to a human-readable format if needed. I should never have bothered trying to serialize a Date object in the first place.

Also, this is the last excuse I needed to learn a new programming language. I've heard good things about Rust. Go seems kind of interesting but Google will delete it one day. I don't mean they will stop supporting Go, I literally mean Google will find a way to erase its very existence. Erlang is all weird and obscure, two positives for me. Then again, 6502 and Z80 assembler are both very tempting...



Related