diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index a2bcfe1..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: "Code scanning - action" - -on: - push: - branches: [master, ] - pull_request: - # The branches below must be a subset of the branches above - branches: [master] - schedule: - - cron: '0 11 * * 5' - -jobs: - CodeQL-Build: - - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..f33965a --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,31 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Java CI with Maven + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml diff --git a/README.md b/README.md index 8cf298e..d26d852 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,5 @@ This is a fork of [nanojson](https://github.com/mmastrac/nanojson) for use by NewPipe(Extractor). It has the following changes: -- It returns an empty `JsonObject` or `JsonArray` by default instead of `null`, preventing `NullPointerException`s. -- It accepts JS-like JSON, such as `{ this: 'is', an: 'example' }`. - Added ``JsonArray#streamAs`` and ``JsonArray#streamAsJsonObjects`` shortcut methods. +- Various performance improvements borrowed from [@FireMasterK's fork](https://github.com/FireMasterK/nanojson). diff --git a/checkstyle.xml b/checkstyle.xml index 9c8ecb2..215efdb 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -20,8 +20,7 @@ - - + - + diff --git a/pom.xml b/pom.xml index 29ca33f..b984eb3 100644 --- a/pom.xml +++ b/pom.xml @@ -13,19 +13,35 @@ nanojson jar nanojson - 1.8-SNAPSHOT + 1.11-SNAPSHOT UTF-8 UTF-8 + 11 + 11 + + + org.junit.jupiter + junit-jupiter + 5.13.4 + test + + + ch.randelshofer + fastdoubleparser + 2.0.1 + compile + + org.apache.maven.plugins maven-javadoc-plugin - 3.3.0 + 3.11.3 html @@ -44,7 +60,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.1.2 + 3.6.0 checkstyle.xml basedir=${basedir} @@ -66,7 +82,7 @@ org.apache.maven.plugins maven-surefire-report-plugin - 2.22.2 + 3.5.3 @@ -77,21 +93,12 @@ - - - - junit - junit - 4.13.2 - test - - org.apache.maven.plugins maven-source-plugin - 3.2.1 + 3.3.1 attach-sources @@ -104,7 +111,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.3.0 + 3.11.3 attach-javadocs @@ -117,7 +124,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.1.2 + 3.6.0 checkstyle.xml basedir=${basedir} @@ -145,7 +152,7 @@ org.codehaus.mojo exec-maven-plugin - 3.0.0 + 3.5.1 java @@ -165,15 +172,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.14.0 - true - none - 1.8 - 1.8 - - src/main/resources/META-INF/MANIFEST.MF - + false + 11 -Xlint:all @@ -183,21 +185,44 @@ org.apache.maven.plugins maven-site-plugin - 3.9.1 + 3.21.0 - - org.apache.maven.plugins - maven-jar-plugin - - - - com.grack.nanojson - - - - + + biz.aQute.bnd + bnd-maven-plugin + true + + + jar + + jar + + + + + + + @@ -210,7 +235,7 @@ org.sonatype.plugins nexus-staging-maven-plugin - 1.6.8 + 1.7.0 true sonatype-nexus-staging @@ -221,7 +246,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.0.1 + 3.2.8 sign-artifacts diff --git a/src/main/java/com/grack/nanojson/CharBufferPool.java b/src/main/java/com/grack/nanojson/CharBufferPool.java new file mode 100644 index 0000000..59819eb --- /dev/null +++ b/src/main/java/com/grack/nanojson/CharBufferPool.java @@ -0,0 +1,69 @@ +/* + * Copyright 2011 The nanojson Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.grack.nanojson; + +import java.nio.CharBuffer; +import java.util.PriorityQueue; +import java.util.concurrent.atomic.AtomicInteger; + +public final class CharBufferPool { + + private static final AtomicInteger SIZE = new AtomicInteger(0); + private static final int MAX_SIZE = 1000; + private static final int MAX_RETAINED_BUFFER_SIZE = 16 * 1024; // 16KB limit for pooled buffers + + private static final PriorityQueue BUFFERS = new PriorityQueue<>(); + + private CharBufferPool() { + } + + public static CharBuffer get(int capacity) { + synchronized (BUFFERS) { + if (!BUFFERS.isEmpty()) { + CharBuffer buffer = BUFFERS.poll(); + if (buffer.capacity() < capacity) { + return CharBuffer.allocate(capacity); + } + return buffer; + } + } + + if (SIZE.incrementAndGet() > MAX_SIZE) { + SIZE.decrementAndGet(); + throw new IllegalStateException("Buffer pool size limit exceeded"); + } + + return CharBuffer.allocate(capacity); + } + + public static void release(CharBuffer buffer) { + if (buffer == null || buffer.capacity() <= 0) { + return; + } + + if (buffer.limit() > MAX_RETAINED_BUFFER_SIZE) { + // If the buffer is too large, decrement SIZE so another can be created + SIZE.decrementAndGet(); + return; + } + + buffer.clear(); + + synchronized (BUFFERS) { + BUFFERS.add(buffer); + } + } +} diff --git a/src/main/java/com/grack/nanojson/JsonArray.java b/src/main/java/com/grack/nanojson/JsonArray.java index 58a8098..3e37ff7 100644 --- a/src/main/java/com/grack/nanojson/JsonArray.java +++ b/src/main/java/com/grack/nanojson/JsonArray.java @@ -215,6 +215,8 @@ public String getString(int key) { */ public String getString(int key, String default_) { Object o = get(key); + if (o instanceof LazyString) + return o.toString(); if (o instanceof String) return (String) o; return default_; @@ -252,7 +254,8 @@ public boolean isNumber(int key) { * Returns true if the array has a string element at that index. */ public boolean isString(int key) { - return get(key) instanceof String; + Object o = get(key); + return o instanceof LazyString || o instanceof String; } /** diff --git a/src/main/java/com/grack/nanojson/JsonBuilder.java b/src/main/java/com/grack/nanojson/JsonBuilder.java index 00bfaf2..a49bbcf 100644 --- a/src/main/java/com/grack/nanojson/JsonBuilder.java +++ b/src/main/java/com/grack/nanojson/JsonBuilder.java @@ -27,6 +27,7 @@ */ public final class JsonBuilder implements JsonSink> { private Stack json = new Stack<>(); + private String pendingKey; private T root; JsonBuilder(T root) { @@ -73,12 +74,20 @@ public JsonBuilder nul(String key) { @Override public JsonBuilder value(Object o) { - arr().add(o); + if (pendingKey != null) { + obj().put(pendingKey, o); + pendingKey = null; + } else { + arr().add(o); + } return this; } @Override public JsonBuilder value(String key, Object o) { + if (pendingKey != null) { + throw new JsonWriterException("Invalid call to emit a key value immediately after emitting a key"); + } obj().put(key, o); return this; } @@ -193,6 +202,18 @@ public JsonBuilder end() { return this; } + @Override + public JsonBuilder key(String key) { + if (key == null) + throw new NullPointerException("key"); + if (!(json.peek() instanceof JsonObject)) + throw new JsonWriterException("Invalid call to emit a key value while not writing an object"); + if (pendingKey != null) + throw new JsonWriterException("Invalid call to emit a key value immediately after emitting a key"); + pendingKey = key; + return this; + } + private JsonObject obj() { try { return (JsonObject)json.peek(); diff --git a/src/main/java/com/grack/nanojson/JsonConvertible.java b/src/main/java/com/grack/nanojson/JsonConvertible.java new file mode 100644 index 0000000..b3f3c1e --- /dev/null +++ b/src/main/java/com/grack/nanojson/JsonConvertible.java @@ -0,0 +1,13 @@ +package com.grack.nanojson; + +/** + * An interface for classes that can be converted into valid JSON values. + */ +public interface JsonConvertible { + /** + * Creates a view of this object as a valid JSON Type. + * + * @return an instance of Map, Collection, String, Number or Boolean or {@code null} + */ + Object toJsonValue(); +} diff --git a/src/main/java/com/grack/nanojson/JsonLazyNumber.java b/src/main/java/com/grack/nanojson/JsonLazyNumber.java index 080c6ef..01becd6 100644 --- a/src/main/java/com/grack/nanojson/JsonLazyNumber.java +++ b/src/main/java/com/grack/nanojson/JsonLazyNumber.java @@ -15,45 +15,52 @@ */ package com.grack.nanojson; -import java.math.BigDecimal; +import ch.randelshofer.fastdoubleparser.JavaBigDecimalParser; +import ch.randelshofer.fastdoubleparser.JavaDoubleParser; +import ch.randelshofer.fastdoubleparser.JavaFloatParser; /** * Lazily-parsed number for performance. */ @SuppressWarnings("serial") class JsonLazyNumber extends Number { - private String value; + private char[] value; private boolean isDouble; - JsonLazyNumber(String number, boolean isDoubleValue) { + JsonLazyNumber(char[] number, boolean isDoubleValue) { this.value = number; this.isDouble = isDoubleValue; } @Override public double doubleValue() { - return Double.parseDouble(value); + return JavaDoubleParser.parseDouble(value); } @Override public float floatValue() { - return Float.parseFloat(value); + return JavaFloatParser.parseFloat(value); } @Override public int intValue() { - return isDouble ? (int)Double.parseDouble(value) : Integer.parseInt(value); + return isDouble ? (int)JavaDoubleParser.parseDouble(value) : Integer.parseInt(new String(value)); } @Override public long longValue() { - return isDouble ? (long)Double.parseDouble(value) : Long.parseLong(value); + return isDouble ? (long) JavaDoubleParser.parseDouble(value) : Long.parseLong(new String(value)); + } + + @Override + public String toString() { + return new String(value); } /** * Avoid serializing {@link JsonLazyNumber}. */ private Object writeReplace() { - return new BigDecimal(value); + return JavaBigDecimalParser.parseBigDecimal(value); } } diff --git a/src/main/java/com/grack/nanojson/JsonObject.java b/src/main/java/com/grack/nanojson/JsonObject.java index 863728b..5703c30 100644 --- a/src/main/java/com/grack/nanojson/JsonObject.java +++ b/src/main/java/com/grack/nanojson/JsonObject.java @@ -206,6 +206,8 @@ public String getString(String key) { */ public String getString(String key, String default_) { Object o = get(key); + if (o instanceof LazyString) + return o.toString(); if (o instanceof String) return (String) o; return default_; @@ -243,6 +245,7 @@ public boolean isNumber(String key) { * Returns true if the object has a string element at that key. */ public boolean isString(String key) { - return get(key) instanceof String; + Object o = get(key); + return o instanceof LazyString || o instanceof String; } } diff --git a/src/main/java/com/grack/nanojson/JsonParser.java b/src/main/java/com/grack/nanojson/JsonParser.java index 3cbbfc0..60f04b7 100644 --- a/src/main/java/com/grack/nanojson/JsonParser.java +++ b/src/main/java/com/grack/nanojson/JsonParser.java @@ -15,16 +15,19 @@ */ package com.grack.nanojson; +import ch.randelshofer.fastdoubleparser.JavaBigIntegerParser; +import ch.randelshofer.fastdoubleparser.JavaDoubleParser; + import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.io.StringReader; -import java.math.BigInteger; import java.net.URL; +import java.util.Arrays; /** * Simple JSON parser. - * + * *
  * Object json = {@link JsonParser}.any().from("{\"a\":[true,false], \"b\":1}");
  * Number json = ({@link Number}){@link JsonParser}.any().from("123.456e7");
@@ -36,18 +39,21 @@ public final class JsonParser {
 	private Object value;
 	private int token;
 
-	private JsonTokener tokener;
-	private boolean lazyNumbers;
+	private final JsonTokener tokener;
+	private final boolean lazyNumbers;
+	private final boolean lazyStrings;
 
 	/**
-	 * Returns a type-safe parser context for a {@link JsonObject}, {@link JsonArray} or "any" type from which you can
+	 * Returns a type-safe parser context for a {@link JsonObject},
+	 * {@link JsonArray} or "any" type from which you can
 	 * parse a {@link String} or a {@link Reader}.
 	 *
 	 * @param  The parsed type.
 	 */
 	public static final class JsonParserContext {
 		private final Class clazz;
-		private boolean lazyNumbers;
+		private boolean lazyNumbers = true;
+		private boolean lazyStrings = true;
 
 		JsonParserContext(Class clazz) {
 			this.clazz = clazz;
@@ -62,18 +68,27 @@ public JsonParserContext withLazyNumbers() {
 			return this;
 		}
 
+		/**
+		 * Parses Strings lazily, allowing us to defer some of the cost of String
+		 * construction until later.
+		 */
+		public JsonParserContext withLazyStrings() {
+			lazyStrings = true;
+			return this;
+		}
+
 		/**
 		 * Parses the current JSON type from a {@link String}.
 		 */
 		public T from(String s) throws JsonParserException {
-			return new JsonParser(new JsonTokener(new StringReader(s)), lazyNumbers).parse(clazz);
+			return new JsonParser(new JsonTokener(new StringReader(s)), lazyNumbers, lazyStrings).parse(clazz);
 		}
 
 		/**
 		 * Parses the current` JSON type from a {@link Reader}.
 		 */
 		public T from(Reader r) throws JsonParserException {
-			return new JsonParser(new JsonTokener(r), lazyNumbers).parse(clazz);
+			return new JsonParser(new JsonTokener(r), lazyNumbers, lazyStrings).parse(clazz);
 		}
 
 		/**
@@ -81,11 +96,8 @@ public T from(Reader r) throws JsonParserException {
 		 */
 		public T from(URL url) throws JsonParserException {
 			try {
-				InputStream stm = url.openStream();
-				try {
+				try (InputStream stm = url.openStream()) {
 					return from(stm);
-				} finally {
-					stm.close();
 				}
 			} catch (IOException e) {
 				throw new JsonParserException(e, "IOException opening URL", 1, 1, 0);
@@ -93,21 +105,23 @@ public T from(URL url) throws JsonParserException {
 		}
 
 		/**
-		 * Parses the current JSON type from a {@link InputStream}. Detects the encoding from the input stream.
+		 * Parses the current JSON type from a {@link InputStream}. Detects the encoding
+		 * from the input stream.
 		 */
 		public T from(InputStream stm) throws JsonParserException {
-			return new JsonParser(new JsonTokener(stm), lazyNumbers).parse(clazz);
+			return new JsonParser(new JsonTokener(stm), lazyNumbers, lazyStrings).parse(clazz);
 		}
 	}
 
-	JsonParser(JsonTokener tokener, boolean lazyNumbers) throws JsonParserException {
+	JsonParser(JsonTokener tokener, boolean lazyNumbers, boolean lazyStrings) throws JsonParserException {
 		this.tokener = tokener;
 		this.lazyNumbers = lazyNumbers;
+		this.lazyStrings = lazyStrings;
 	}
 
 	/**
 	 * Parses a {@link JsonObject} from a source.
-	 * 
+	 *
 	 * 
 	 * JsonObject json = {@link JsonParser}.object().from("{\"a\":[true,false], \"b\":1}");
 	 * 
@@ -118,7 +132,7 @@ public static JsonParserContext object() { /** * Parses a {@link JsonArray} from a source. - * + * *
 	 * JsonArray json = {@link JsonParser}.array().from("[1, {\"a\":[true,false], \"b\":1}]");
 	 * 
@@ -128,9 +142,11 @@ public static JsonParserContext array() { } /** - * Parses any object from a source. For any valid JSON, returns either a null (for the JSON string 'null'), a - * {@link String}, a {@link Number}, a {@link Boolean}, a {@link JsonObject} or a {@link JsonArray}. - * + * Parses any object from a source. For any valid JSON, returns either a null + * (for the JSON string 'null'), a + * {@link String}, a {@link Number}, a {@link Boolean}, a {@link JsonObject} or + * a {@link JsonArray}. + * *
 	 * Object json = {@link JsonParser}.any().from("{\"a\":[true,false], \"b\":1}");
 	 * Number json = ({@link Number}){@link JsonParser}.any().from("123.456e7");
@@ -144,138 +160,138 @@ public static JsonParserContext any() {
 	 * Parse a single JSON value from the string, expecting an EOF at the end.
 	 */
 	 T parse(Class clazz) throws JsonParserException {
-		advanceToken(false, false);
-		Object parsed = currentValue();
-		if (advanceToken(false, false) != JsonTokener.TOKEN_EOF)
-			throw tokener.createParseException(null, "Expected end of input, got " + token, true);
-		if (clazz != Object.class && (parsed == null || !clazz.isAssignableFrom(parsed.getClass())))
-			throw tokener.createParseException(null,
-					"JSON did not contain the correct type, expected " + clazz.getSimpleName() + ".", 
-					true);
-		return clazz.cast(parsed);
+		try {
+			advanceToken();
+			Object parsed = currentValue();
+			if (advanceToken() != JsonTokener.TOKEN_EOF)
+				throw tokener.createParseException(null, "Expected end of input, got " + token, true);
+			if (clazz != Object.class && (parsed == null || !clazz.isAssignableFrom(parsed.getClass())))
+				throw tokener.createParseException(null,
+						"JSON did not contain the correct type, expected " + clazz.getSimpleName() + ".",
+						true);
+			return clazz.cast(parsed);
+		} finally {
+			// Automatically close the tokener to release resources back to the pool
+			try {
+				tokener.close();
+			} catch (IOException e) {
+				// Log or ignore IOException during cleanup - don't mask the original exception
+			}
+		}
 	}
 
 	/**
 	 * Starts parsing a JSON value at the current token position.
 	 */
 	private Object currentValue() throws JsonParserException {
-		// Only a value start token should appear when we're in the context of parsing a JSON value
+		// Only a value start token should appear when we're in the context of parsing a
+		// JSON value
 		if (token >= JsonTokener.TOKEN_VALUE_MIN)
 			return value;
 		throw tokener.createParseException(null, "Expected JSON value, got " + token, true);
 	}
 
 	/**
-	 * Consumes a token, first eating up any whitespace ahead of it. Note that number tokens are not necessarily valid
+	 * Consumes a token, first eating up any whitespace ahead of it. Note that
+	 * number tokens are not necessarily valid
 	 * numbers.
 	 */
-	private int advanceToken(boolean allowSemiString, boolean old) throws JsonParserException {
-		if (old) tokener.index--;
-		token = tokener.advanceToToken(allowSemiString);
+	private int advanceToken() throws JsonParserException {
+		token = tokener.advanceToToken();
 		switch (token) {
-		case JsonTokener.TOKEN_ARRAY_START: // Inlined function to avoid additional stack
-			JsonArray list = new JsonArray();
-			if (advanceToken(false, false) != JsonTokener.TOKEN_ARRAY_END)
-				while (true) {
-					list.add(currentValue());
-					if (token == JsonTokener.TOKEN_SEMI_STRING)
-						throw tokener.createParseException(null, "Semi-string is not allowed in array", true);
-					if (advanceToken(false, false) == JsonTokener.TOKEN_ARRAY_END)
-						break;
-					if (token != JsonTokener.TOKEN_COMMA)
-						throw tokener.createParseException(null,
-								"Expected a comma or end of the array instead of " + token, true);
-					if (advanceToken(false, false) == JsonTokener.TOKEN_ARRAY_END)
-						throw tokener.createParseException(null, "Trailing comma found in array", true);
-				}
-			value = list;
-			return token = JsonTokener.TOKEN_ARRAY_START;
-		case JsonTokener.TOKEN_OBJECT_START: // Inlined function to avoid additional stack
-			JsonObject map = new JsonObject();
-			if (advanceToken(true, false) != JsonTokener.TOKEN_OBJECT_END)
-				while (true) {
-					switch (token) {
-					case JsonTokener.TOKEN_NULL:
-					case JsonTokener.TOKEN_TRUE:
-					case JsonTokener.TOKEN_FALSE:
-						value = value.toString();
-						break;
-					case JsonTokener.TOKEN_STRING:
-					case JsonTokener.TOKEN_SEMI_STRING:
-						break;
-					default:
-						throw tokener.createParseException(null, "Expected STRING, got " + token, true);
+			case JsonTokener.TOKEN_ARRAY_START: // Inlined function to avoid additional stack
+				JsonArray list = new JsonArray();
+				if (advanceToken() != JsonTokener.TOKEN_ARRAY_END)
+					while (true) {
+						list.add(currentValue());
+						if (advanceToken() == JsonTokener.TOKEN_ARRAY_END)
+							break;
+						if (token != JsonTokener.TOKEN_COMMA)
+							throw tokener.createParseException(null,
+									"Expected a comma or end of the array instead of " + token, true);
+						if (advanceToken() == JsonTokener.TOKEN_ARRAY_END)
+							throw tokener.createParseException(null, "Trailing comma found in array", true);
 					}
-					String key = (String)value;
-					if (token == JsonTokener.TOKEN_SEMI_STRING) {
-						if (advanceToken(false, true) != JsonTokener.TOKEN_COLON)
+				value = list;
+				return token = JsonTokener.TOKEN_ARRAY_START;
+			case JsonTokener.TOKEN_OBJECT_START: // Inlined function to avoid additional stack
+				JsonObject map = new JsonObject();
+				if (advanceToken() != JsonTokener.TOKEN_OBJECT_END)
+					while (true) {
+						if (token != JsonTokener.TOKEN_STRING)
+							throw tokener.createParseException(null, "Expected STRING, got " + token, true);
+						String key = lazyStrings ? value.toString() : (String) value;
+						if (advanceToken() != JsonTokener.TOKEN_COLON)
 							throw tokener.createParseException(null, "Expected COLON, got " + token, true);
-					} else if (advanceToken(false, false) != JsonTokener.TOKEN_COLON)
-						throw tokener.createParseException(null, "Expected COLON, got " + token, true);
-					advanceToken(false, false);
-					map.put(key, currentValue());
-					if (advanceToken(false, false) == JsonTokener.TOKEN_OBJECT_END)
-						break;
-					if (token != JsonTokener.TOKEN_COMMA)
-						throw tokener.createParseException(null,
-								"Expected a comma or end of the object instead of " + token, true);
-					if (advanceToken(true, false) == JsonTokener.TOKEN_OBJECT_END)
-						throw tokener.createParseException(null, "Trailing object found in array", true);
+						advanceToken();
+						map.put(key, currentValue());
+						if (advanceToken() == JsonTokener.TOKEN_OBJECT_END)
+							break;
+						if (token != JsonTokener.TOKEN_COMMA)
+							throw tokener.createParseException(null,
+									"Expected a comma or end of the object instead of " + token, true);
+						if (advanceToken() == JsonTokener.TOKEN_OBJECT_END)
+							throw tokener.createParseException(null, "Trailing object found in array", true);
+					}
+				value = map;
+				return token = JsonTokener.TOKEN_OBJECT_START;
+			case JsonTokener.TOKEN_TRUE:
+				value = Boolean.TRUE;
+				break;
+			case JsonTokener.TOKEN_FALSE:
+				value = Boolean.FALSE;
+				break;
+			case JsonTokener.TOKEN_NULL:
+				value = null;
+				break;
+			case JsonTokener.TOKEN_STRING:
+				char[] chars = tokener.reusableBuffer.array();
+				chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+				value = lazyStrings ? new LazyString(chars) : new String(chars);
+				break;
+			case JsonTokener.TOKEN_NUMBER:
+				if (lazyNumbers) {
+					chars = tokener.reusableBuffer.array();
+					chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+					value = new JsonLazyNumber(chars, tokener.isDouble);
+				} else {
+					value = parseNumber();
 				}
-			value = map;
-			return token = JsonTokener.TOKEN_OBJECT_START;
-		case JsonTokener.TOKEN_TRUE:
-			value = Boolean.TRUE;
-			break;
-		case JsonTokener.TOKEN_FALSE:
-			value = Boolean.FALSE;
-			break;
-		case JsonTokener.TOKEN_NULL:
-			value = null;
-			break;
-		case JsonTokener.TOKEN_STRING:
-		case JsonTokener.TOKEN_SEMI_STRING:
-			value = tokener.reusableBuffer.toString();
-			break;
-		case JsonTokener.TOKEN_NUMBER:
-			if (lazyNumbers) {
-				value = new JsonLazyNumber(tokener.reusableBuffer.toString(), tokener.isDouble);
-			} else {
-				value = parseNumber();
-			}
-			break;
-		default:
+				break;
+			default:
 		}
 
 		return token;
 	}
 
 	private Number parseNumber() throws JsonParserException {
-		String number = tokener.reusableBuffer.toString();
+		char[] number = tokener.reusableBuffer.array();
+		number = Arrays.copyOf(number, tokener.reusableBuffer.position());
+		int numLength = number.length;
 
 		try {
 			if (tokener.isDouble)
-				return Double.parseDouble(number);
+				return JavaDoubleParser.parseDouble(number);
 
 			// Quick parse for single-digits
-			if (number.length() == 1) {
-				return number.charAt(0) - '0';
-			} else if (number.length() == 2 && number.charAt(0) == '-') {
-				return '0' - number.charAt(1);
+			if (numLength == 1) {
+				return number[0] - '0';
+			} else if (numLength == 2 && number[0] == '-') {
+				return '0' - number[1];
 			}
 
 			// HACK: Attempt to parse using the approximate best type for this
-			boolean firstMinus = number.charAt(0) == '-';
-			int length = firstMinus ? number.length() - 1 : number.length();
+			boolean firstMinus = number[0] == '-';
+			int length = firstMinus ? numLength - 1 : numLength;
 			// CHECKSTYLE_OFF: MagicNumber
-			if (length < 10 || (length == 10 && number.charAt(firstMinus ? 1 : 0) < '2')) // 2 147 483 647
-				return Integer.parseInt(number);
-			if (length < 19 || (length == 19 && number.charAt(firstMinus ? 1 : 0) < '9')) // 9 223 372 036 854 775 807
-				return Long.parseLong(number);
+			if (length < 10 || (length == 10 && number[firstMinus ? 1 : 0] < '2')) // 2 147 483 647
+				return Integer.parseInt(new String(number));
+			if (length < 19 || (length == 19 && number[firstMinus ? 1 : 0] < '9')) // 9 223 372 036 854 775 807
+				return Long.parseLong(new String(number));
 			// CHECKSTYLE_ON: MagicNumber
-			return new BigInteger(number);
+			return JavaBigIntegerParser.parseBigInteger(number);
 		} catch (NumberFormatException e) {
-			throw tokener.createParseException(e, "Malformed number: " + number, true);
+			throw tokener.createParseException(e, "Malformed number: " + new String(number), true);
 		}
 	}
 }
diff --git a/src/main/java/com/grack/nanojson/JsonReader.java b/src/main/java/com/grack/nanojson/JsonReader.java
index b9ea2cb..eddae48 100644
--- a/src/main/java/com/grack/nanojson/JsonReader.java
+++ b/src/main/java/com/grack/nanojson/JsonReader.java
@@ -15,22 +15,31 @@
  */
 package com.grack.nanojson;
 
+import ch.randelshofer.fastdoubleparser.JavaDoubleParser;
+import ch.randelshofer.fastdoubleparser.JavaFloatParser;
+
+import java.io.Closeable;
+import java.io.IOException;
 import java.io.InputStream;
+import java.io.Reader;
 import java.io.StringReader;
+import java.nio.CharBuffer;
 import java.util.Arrays;
 import java.util.BitSet;
 
 /**
  * Streaming reader for JSON documents.
  */
-public final class JsonReader {
+public final class JsonReader implements Closeable {
 	private JsonTokener tokener;
 	private int token;
 	private BitSet states = new BitSet();
 	private int stateIndex = 0;
 	private boolean inObject;
 	private boolean first = true;
-	private StringBuilder key = new StringBuilder();
+	// CHECKSTYLE_OFF: MagicNumber
+	private CharBuffer key = CharBufferPool.get(1024);
+	// CHECKSTYLE_ON: MagicNumber
 
 	/**
 	 * The type of value that the {@link JsonReader} is positioned over.
@@ -76,12 +85,19 @@ public static JsonReader from(String s) throws JsonParserException {
 		return new JsonReader(new JsonTokener(new StringReader(s)));
 	}
 
+	/**
+	 * Create a {@link JsonReader} from a {@link Reader}.
+	 */
+	public static JsonReader from(Reader reader) throws JsonParserException {
+		return new JsonReader(new JsonTokener(reader));
+	}
+
 	/**
 	 * Internal constructor.
 	 */
 	JsonReader(JsonTokener tokener) throws JsonParserException {
 		this.tokener = tokener;
-		token = tokener.advanceToToken(false);
+		token = tokener.advanceToToken();
 	}
 
 	/**
@@ -90,7 +106,8 @@ public static JsonReader from(String s) throws JsonParserException {
 	 */
 	public boolean pop() throws JsonParserException {
 		// CHECKSTYLE_OFF: EmptyStatement
-		while (!next());
+		while (!next())
+			;
 		// CHECKSTYLE_ON: EmptyStatement
 		first = false;
 		inObject = states.get(--stateIndex);
@@ -102,23 +119,23 @@ public boolean pop() throws JsonParserException {
 	 */
 	public Type current() throws JsonParserException {
 		switch (token) {
-		case JsonTokener.TOKEN_TRUE:
-		case JsonTokener.TOKEN_FALSE:
-			return Type.BOOLEAN;
-		case JsonTokener.TOKEN_NULL:
-			return Type.NULL;
-		case JsonTokener.TOKEN_NUMBER:
-			return Type.NUMBER;
-		case JsonTokener.TOKEN_STRING:
-			return Type.STRING;
-		case JsonTokener.TOKEN_OBJECT_START:
-			return Type.OBJECT;
-		case JsonTokener.TOKEN_ARRAY_START:
-			return Type.ARRAY;
-		default:				
-			throw createTokenMismatchException(JsonTokener.TOKEN_NULL, JsonTokener.TOKEN_TRUE, 
-					JsonTokener.TOKEN_FALSE, JsonTokener.TOKEN_NUMBER, JsonTokener.TOKEN_STRING,
-					JsonTokener.TOKEN_OBJECT_START, JsonTokener.TOKEN_ARRAY_START);
+			case JsonTokener.TOKEN_TRUE:
+			case JsonTokener.TOKEN_FALSE:
+				return Type.BOOLEAN;
+			case JsonTokener.TOKEN_NULL:
+				return Type.NULL;
+			case JsonTokener.TOKEN_NUMBER:
+				return Type.NUMBER;
+			case JsonTokener.TOKEN_STRING:
+				return Type.STRING;
+			case JsonTokener.TOKEN_OBJECT_START:
+				return Type.OBJECT;
+			case JsonTokener.TOKEN_ARRAY_START:
+				return Type.ARRAY;
+			default:
+				throw createTokenMismatchException(JsonTokener.TOKEN_NULL, JsonTokener.TOKEN_TRUE,
+						JsonTokener.TOKEN_FALSE, JsonTokener.TOKEN_NUMBER, JsonTokener.TOKEN_STRING,
+						JsonTokener.TOKEN_OBJECT_START, JsonTokener.TOKEN_ARRAY_START);
 		}
 	}
 
@@ -134,12 +151,15 @@ public void object() throws JsonParserException {
 	}
 
 	/**
-	 * Reads the key for the object at the current value. Does not advance to the next value.
+	 * Reads the key for the object at the current value. Does not advance to the
+	 * next value.
 	 */
 	public String key() throws JsonParserException {
 		if (!inObject)
 			throw tokener.createParseException(null, "Not reading an object", true);
-		return key.toString();
+		char[] chars = key.array();
+		chars = Arrays.copyOf(chars, key.position());
+		return new String(chars);
 	}
 
 	/**
@@ -158,19 +178,20 @@ public void array() throws JsonParserException {
 	 */
 	public Object value() throws JsonParserException {
 		switch (token) {
-		case JsonTokener.TOKEN_TRUE:
-			return true;
-		case JsonTokener.TOKEN_FALSE:
-			return false;
-		case JsonTokener.TOKEN_NULL:
-			return null;
-		case JsonTokener.TOKEN_NUMBER:
-			return number();
-		case JsonTokener.TOKEN_STRING:
-			return string();
-		default:				
-			throw createTokenMismatchException(JsonTokener.TOKEN_NULL, JsonTokener.TOKEN_TRUE, JsonTokener.TOKEN_FALSE,
-					JsonTokener.TOKEN_NUMBER, JsonTokener.TOKEN_STRING);
+			case JsonTokener.TOKEN_TRUE:
+				return true;
+			case JsonTokener.TOKEN_FALSE:
+				return false;
+			case JsonTokener.TOKEN_NULL:
+				return null;
+			case JsonTokener.TOKEN_NUMBER:
+				return number();
+			case JsonTokener.TOKEN_STRING:
+				return string();
+			default:
+				throw createTokenMismatchException(JsonTokener.TOKEN_NULL, JsonTokener.TOKEN_TRUE,
+						JsonTokener.TOKEN_FALSE,
+						JsonTokener.TOKEN_NUMBER, JsonTokener.TOKEN_STRING);
 		}
 	}
 
@@ -190,7 +211,9 @@ public String string() throws JsonParserException {
 			return null;
 		if (token != JsonTokener.TOKEN_STRING)
 			throw createTokenMismatchException(JsonTokener.TOKEN_NULL, JsonTokener.TOKEN_STRING);
-		return tokener.reusableBuffer.toString();
+		char[] chars = tokener.reusableBuffer.array();
+		chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+		return new String(chars);
 	}
 
 	/**
@@ -211,55 +234,61 @@ else if (token == JsonTokener.TOKEN_FALSE)
 	public Number number() throws JsonParserException {
 		if (token == JsonTokener.TOKEN_NULL)
 			return null;
-		return new JsonLazyNumber(tokener.reusableBuffer.toString(), tokener.isDouble);
+		char[] chars = tokener.reusableBuffer.array();
+		chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+		return new JsonLazyNumber(chars, tokener.isDouble);
 	}
 
 	/**
 	 * Parses the current value as a long.
 	 */
 	public long longVal() throws JsonParserException {
-		String s = tokener.reusableBuffer.toString();
-		return tokener.isDouble ? (long)Double.parseDouble(s) : Long.parseLong(s);
+		char[] chars = tokener.reusableBuffer.array();
+		chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+		return tokener.isDouble ? (long) JavaDoubleParser.parseDouble(chars) : Long.parseLong(new String(chars));
 	}
 
 	/**
 	 * Parses the current value as an integer.
 	 */
 	public int intVal() throws JsonParserException {
-		String s = tokener.reusableBuffer.toString();
-		return tokener.isDouble ? (int)Double.parseDouble(s) : Integer.parseInt(s);
+		char[] chars = tokener.reusableBuffer.array();
+		chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+		return tokener.isDouble ? (int) JavaDoubleParser.parseDouble(chars) : Integer.parseInt(new String(chars));
 	}
 
 	/**
 	 * Parses the current value as a float.
 	 */
 	public float floatVal() throws JsonParserException {
-		String s = tokener.reusableBuffer.toString();
-		return Float.parseFloat(s);
+		char[] chars = tokener.reusableBuffer.array();
+		chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+		return JavaFloatParser.parseFloat(chars);
 	}
 
 	/**
 	 * Parses the current value as a double.
 	 */
 	public double doubleVal() throws JsonParserException {
-		String s = tokener.reusableBuffer.toString();
-		return Double.parseDouble(s);
+		char[] chars = tokener.reusableBuffer.array();
+		chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+		return JavaDoubleParser.parseDouble(chars);
 	}
 
 	/**
 	 * Advance to the next value in this array or object. If no values remain,
 	 * return to the parent array or object.
-	 * 
+	 *
 	 * @return true if we still have values to read in this array or object,
 	 *         false if we have completed this object (and implicitly moved back
 	 *         to the parent array or object)
 	 */
 	public boolean next() throws JsonParserException {
 		if (stateIndex == 0) {
-			throw tokener.createParseException(null, "Unabled to call next() at the root", true); 
+			throw tokener.createParseException(null, "Unabled to call next() at the root", true);
 		}
-		
-		token = tokener.advanceToToken(false);
+
+		token = tokener.advanceToToken();
 
 		if (inObject) {
 			if (token == JsonTokener.TOKEN_OBJECT_END) {
@@ -267,20 +296,22 @@ public boolean next() throws JsonParserException {
 				first = false;
 				return false;
 			}
-			
+
 			if (!first) {
 				if (token != JsonTokener.TOKEN_COMMA)
 					throw createTokenMismatchException(JsonTokener.TOKEN_COMMA, JsonTokener.TOKEN_OBJECT_END);
-				token = tokener.advanceToToken(false);
+				token = tokener.advanceToToken();
 			}
 
 			if (token != JsonTokener.TOKEN_STRING)
 				throw createTokenMismatchException(JsonTokener.TOKEN_STRING);
-			key.setLength(0);
-			key.append(tokener.reusableBuffer); // reduce string garbage 
-			if ((token = tokener.advanceToToken(false)) != JsonTokener.TOKEN_COLON)
+			key.clear();
+			char[] chars = tokener.reusableBuffer.array();
+			chars = Arrays.copyOf(chars, tokener.reusableBuffer.position());
+			key.put(chars);
+			if ((token = tokener.advanceToToken()) != JsonTokener.TOKEN_COLON)
 				throw createTokenMismatchException(JsonTokener.TOKEN_COLON);
-			token = tokener.advanceToToken(false);
+			token = tokener.advanceToToken();
 		} else {
 			if (token == JsonTokener.TOKEN_ARRAY_END) {
 				inObject = states.get(--stateIndex);
@@ -290,7 +321,7 @@ public boolean next() throws JsonParserException {
 			if (!first) {
 				if (token != JsonTokener.TOKEN_COMMA)
 					throw createTokenMismatchException(JsonTokener.TOKEN_COMMA, JsonTokener.TOKEN_ARRAY_END);
-				token = tokener.advanceToToken(false);
+				token = tokener.advanceToToken();
 			}
 		}
 
@@ -303,13 +334,28 @@ public boolean next() throws JsonParserException {
 					JsonTokener.TOKEN_OBJECT_START, JsonTokener.TOKEN_ARRAY_START);
 
 		first = false;
-		
+
 		return true;
 	}
-	
+
+	/**
+	 * Releases resources used by this JsonReader. Should be called when done
+	 * reading.
+	 */
+	@Override
+	public void close() throws IOException {
+		if (key != null) {
+			CharBufferPool.release(key);
+			key = null;
+		}
+		if (tokener != null) {
+			tokener.close();
+		}
+	}
+
 	private JsonParserException createTokenMismatchException(int... t) {
 		return tokener.createParseException(null, "token mismatch (expected " + Arrays.toString(t)
-						+ ", was " + token + ")",
+				+ ", was " + token + ")",
 				true);
 	}
 }
diff --git a/src/main/java/com/grack/nanojson/JsonSink.java b/src/main/java/com/grack/nanojson/JsonSink.java
index 73d8f02..4f30b6a 100644
--- a/src/main/java/com/grack/nanojson/JsonSink.java
+++ b/src/main/java/com/grack/nanojson/JsonSink.java
@@ -159,4 +159,9 @@ public interface JsonSink> {
 	 * Ends the current array or object.
 	 */
 	SELF end();
+
+	/**
+	 * Writes the key of a key/value pair.
+	 */
+	SELF key(String key);
 }
diff --git a/src/main/java/com/grack/nanojson/JsonTokener.java b/src/main/java/com/grack/nanojson/JsonTokener.java
index d7a45a2..3fa5807 100644
--- a/src/main/java/com/grack/nanojson/JsonTokener.java
+++ b/src/main/java/com/grack/nanojson/JsonTokener.java
@@ -17,20 +17,25 @@
 
 import java.io.BufferedInputStream;
 import java.io.ByteArrayInputStream;
+import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.Reader;
+import java.nio.CharBuffer;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 
 /**
- * Internal class for tokenizing JSON. Used by both {@link JsonParser} and {@link JsonReader}.
+ * Internal class for tokenizing JSON. Used by both {@link JsonParser} and
+ * {@link JsonReader}.
  */
-final class JsonTokener {
+final class JsonTokener implements Closeable {
 	// Used by tests
 	static final int BUFFER_SIZE = 32 * 1024;
 
+	static final int MAX_CHAR_BUFFER_SIZE = 512;
+
 	static final int BUFFER_ROOM = 256;
 	static final int MAX_ESCAPE = 5; // uXXXX (don't need the leading slash)
 
@@ -38,14 +43,14 @@ final class JsonTokener {
 	private int tokenCharPos, tokenCharOffset;
 
 	private boolean eof;
-	protected int index;
+	private int index;
 	private final Reader reader;
 	private final char[] buffer = new char[BUFFER_SIZE];
 	private int bufferLength;
 
 	private final boolean utf8;
 
-	protected StringBuilder reusableBuffer = new StringBuilder();
+	protected CharBuffer reusableBuffer = CharBufferPool.get(MAX_CHAR_BUFFER_SIZE);
 	protected boolean isDouble;
 
 	static final char[] TRUE = { 'r', 'u', 'e' };
@@ -64,11 +69,11 @@ final class JsonTokener {
 	static final int TOKEN_NUMBER = 9;
 	static final int TOKEN_OBJECT_START = 10;
 	static final int TOKEN_ARRAY_START = 11;
-	static final int TOKEN_SEMI_STRING = 12;
 	static final int TOKEN_VALUE_MIN = TOKEN_NULL;
 
 	/**
-	 * A {@link Reader} that reads a UTF8 stream without decoding it for performance.
+	 * A {@link Reader} that reads a UTF8 stream without decoding it for
+	 * performance.
 	 */
 	private static final class PseudoUtf8Reader extends Reader {
 		private final InputStream buffered;
@@ -82,7 +87,7 @@ private static final class PseudoUtf8Reader extends Reader {
 		public int read(char[] cbuf, int off, int len) throws IOException {
 			int r = buffered.read(buf, off, len);
 			for (int i = off; i < off + r; i++)
-				cbuf[i] = (char)buf[i];
+				cbuf[i] = (char) buf[i];
 			return r;
 		}
 
@@ -90,15 +95,15 @@ public int read(char[] cbuf, int off, int len) throws IOException {
 		public void close() throws IOException {
 		}
 	}
-	
+
 	JsonTokener(Reader reader) throws JsonParserException {
 		this.reader = reader;
 		this.utf8 = false;
 		init();
 	}
-	
+
 	JsonTokener(InputStream stm) throws JsonParserException {
-		final InputStream buffered = (stm instanceof BufferedInputStream || stm instanceof ByteArrayInputStream) 
+		final InputStream buffered = (stm instanceof BufferedInputStream || stm instanceof ByteArrayInputStream)
 				? stm
 				: new BufferedInputStream(stm);
 		buffered.mark(4);
@@ -176,30 +181,17 @@ void consumeKeyword(char first, char[] expected) throws JsonParserException {
 
 		fixupAfterRawBufferRead();
 
-		// The token shouldn't end with something other than an ASCII letter
-		switch (peekChar()) {
-		case ',':
-		case ':':
-		case '{':
-		case '}':
-		case '[':
-		case ']':
-		case ' ':
-		case '\n':
-		case '\r':
-		case '\t':
-			break;
-		default:
+		// The token should end with something other than an ASCII letter
+		if (isAsciiLetter(peekChar()))
 			throw createHelpfulException(first, expected, expected.length);
-		}
 	}
 
 	/**
 	 * Steps through to the end of the current number token (a non-digit token).
 	 */
 	void consumeTokenNumber(char savedChar) throws JsonParserException {
-		reusableBuffer.setLength(0);
-		reusableBuffer.append(savedChar);
+		reusableBuffer.clear();
+		reusableBuffer.put(savedChar);
 		isDouble = false;
 
 		// The JSON spec is way stricter about number formats than
@@ -213,9 +205,8 @@ void consumeTokenNumber(char savedChar) throws JsonParserException {
 		} else {
 			state = 2;
 		}
-		
-		outer:
-		while (true) {
+
+		outer: while (true) {
 			int n = ensureBuffer(BUFFER_ROOM);
 			if (n == 0)
 				break outer;
@@ -226,241 +217,129 @@ void consumeTokenNumber(char savedChar) throws JsonParserException {
 					break outer;
 
 				int ns = -1;
-				sw:
-				switch (state) {
-				case 1: // start leading negative
-					if (nc == '0') {
-						ns = 3; break sw;
-					}
-					if (nc > '0' && nc <= '9') {
-						ns = 2; break sw;
-					}
-					break;
-				case 2: // no leading zero
-				case 3: // leading zero
-					if ((nc >= '0' && nc <= '9') && state == 2) {
-						ns = 2; break sw;
-					}
-					if (nc == '.') {
-						isDouble = true;
-						ns = 4; break sw;
-					}
-					if (nc == 'e' || nc == 'E') {
-						isDouble = true;
-						ns = 6; break sw;
-					}
-					break;
-				case 4: // after period
-				case 5: // after period, one digit read
-					if (nc >= '0' && nc <= '9') {
-						ns = 5; break sw;
-					}
-					if ((nc == 'e' || nc == 'E') && state == 5) {
-						isDouble = true;
-						ns = 6; break sw;
-					}
-					break;
-				case 6: // after exponent
-				case 7: // after exponent and sign
-					if (nc == '+' || nc == '-' && state == 6) {
-						ns = 7; break sw;
-					}
-					if (nc >= '0' && nc <= '9') {
-						ns = 8; break sw;
-					}
-					break;
-				case 8: // after digits
-					if (nc >= '0' && nc <= '9') {
-						ns = 8; break sw;
-					}
-					break;
-				default:
-					assert false : "Impossible"; // will throw malformed number
+				sw: switch (state) {
+					case 1: // start leading negative
+						if (nc == '0') {
+							ns = 3;
+							break sw;
+						}
+						if (nc > '0' && nc <= '9') {
+							ns = 2;
+							break sw;
+						}
+						break;
+					case 2: // no leading zero
+					case 3: // leading zero
+						if ((nc >= '0' && nc <= '9') && state == 2) {
+							ns = 2;
+							break sw;
+						}
+						if (nc == '.') {
+							isDouble = true;
+							ns = 4;
+							break sw;
+						}
+						if (nc == 'e' || nc == 'E') {
+							isDouble = true;
+							ns = 6;
+							break sw;
+						}
+						break;
+					case 4: // after period
+					case 5: // after period, one digit read
+						if (nc >= '0' && nc <= '9') {
+							ns = 5;
+							break sw;
+						}
+						if ((nc == 'e' || nc == 'E') && state == 5) {
+							isDouble = true;
+							ns = 6;
+							break sw;
+						}
+						break;
+					case 6: // after exponent
+					case 7: // after exponent and sign
+						if (nc == '+' || nc == '-' && state == 6) {
+							ns = 7;
+							break sw;
+						}
+						if (nc >= '0' && nc <= '9') {
+							ns = 8;
+							break sw;
+						}
+						break;
+					case 8: // after digits
+						if (nc >= '0' && nc <= '9') {
+							ns = 8;
+							break sw;
+						}
+						break;
+					default:
+						assert false : "Impossible"; // will throw malformed number
 				}
-				reusableBuffer.append(nc);
+				reusableBuffer.put(nc);
 				index++;
 				if (ns == -1)
 					throw createParseException(null, "Malformed number: " + reusableBuffer, true);
 				state = ns;
 			}
 		}
-		
+
 		if (state != 2 && state != 3 && state != 5 && state != 8)
 			throw createParseException(null, "Malformed number: " + reusableBuffer, true);
-		
+
 		// Special case for -0
 		if (state == 3 && savedChar == '-')
 			isDouble = true;
-		
+
 		fixupAfterRawBufferRead();
 	}
 
 	/**
-	 * Steps through to the end of the current string token (the unescaped double quote).
+	 * Steps through to the end of the current string token (the unescaped double
+	 * quote).
 	 */
-	void consumeTokenString(int cc) throws JsonParserException {
-		reusableBuffer.setLength(0);
-		
-		// Assume no escapes or UTF-8 in the string to start (fast path)
-		start:
-		while (true) {
-			int n = ensureBuffer(BUFFER_ROOM);
-			if (n == 0)
-				throw createParseException(null, "String was not terminated before end of input", true);
-			
-			for (int i = 0; i < n; i++) {
-				char c = stringChar();
-				if (c == cc) {
-					// Use the index before we fixup
-					reusableBuffer.append(buffer, index - i - 1, i);
-					fixupAfterRawBufferRead();
-					return;
-				}
-				if (c == '\\' || (utf8 && (c & 0x80) != 0)) {
-					reusableBuffer.append(buffer, index - i - 1, i);
-					index--;
-					break start;
-				}
-			}
-			
-			reusableBuffer.append(buffer, index - n, n);
-		}
-		
-		outer:
-		while (true) {
-			int n = ensureBuffer(BUFFER_ROOM);
-			if (n == 0)
-				throw createParseException(null, "String was not terminated before end of input", true);
-	
-			int end = index + n;
-			while (index < end) {
-				char c = stringChar();
-				
-				if (utf8 && (c & 0x80) != 0) {
-					// If it's a UTF-8 codepoint, we know it won't have special meaning
-					consumeTokenStringUtf8Char(c);
-					continue outer;
-				}
-	
-				switch (c) {
-				case '"':
-				case '\'':
-					if (c == cc) {
-						fixupAfterRawBufferRead();
-						return;
-					} else {
-						reusableBuffer.append(c);
-						break;
-					}
-				case '\\':
-					// Ensure that we have at least MAX_ESCAPE here in the buffer
-					if (end - index < MAX_ESCAPE) {
-						// Re-adjust the buffer end, unlikely path
-						n = ensureBuffer(MAX_ESCAPE);
-						end = index + n;
-						// Make sure that there's enough chars for a \\uXXXX escape
-						if (buffer[index] == 'u' && n < MAX_ESCAPE) {
-							index = bufferLength; // Reset index to last valid location
-							throw createParseException(null, "EOF encountered in the middle of a string escape", false);
-						}
-					}
-					char escape = buffer[index++];
-					switch (escape) {
-					case 'b':
-						reusableBuffer.append('\b');
-						break;
-					case 'f':
-						reusableBuffer.append('\f');
-						break;
-					case 'n':
-						reusableBuffer.append('\n');
-						break;
-					case 'r':
-						reusableBuffer.append('\r');
-						break;
-					case 't':
-						reusableBuffer.append('\t');
-						break;
-					case '"':
-					case '\'':
-					case '/':
-					case '\\':
-						reusableBuffer.append(escape);
-						break;
-					case 'u':
-						int escaped = 0;
-	
-						for (int j = 0; j < 4; j++) {
-							escaped <<= 4;
-							int digit = buffer[index++];
-							if (digit >= '0' && digit <= '9') {
-								escaped |= (digit - '0');
-							} else if (digit >= 'A' && digit <= 'F') {
-								escaped |= (digit - 'A') + 10;
-							} else if (digit >= 'a' && digit <= 'f') {
-								escaped |= (digit - 'a') + 10;
-							} else {
-								throw createParseException(null, "Expected unicode hex escape character: "
-									+ (char)digit + " (" + digit + ")", false);
-							}
-						}
-	
-						reusableBuffer.append((char)escaped);
-						break;
-					default:
-						throw createParseException(null, "Invalid escape: \\" + escape, false);
-					}
-					break;
-				default:
-					reusableBuffer.append(c);
-				}
-			}
-			
-			if (index > bufferLength) {
-				index = bufferLength; // Reset index to last valid location
-				throw createParseException(null, "EOF encountered in the middle of a string escape", false);
-			}
-		}
-	}
+	void consumeTokenString() throws JsonParserException {
+		reusableBuffer.position(0);
 
-	void consumeTokenSemiString() throws JsonParserException {
-		reusableBuffer.setLength(0);
-
-		start:
-		while (true) {
+		// Assume no escapes or UTF-8 in the string to start (fast path)
+		start: while (true) {
 			int n = ensureBuffer(BUFFER_ROOM);
 			if (n == 0)
 				throw createParseException(null, "String was not terminated before end of input", true);
 
 			for (int i = 0; i < n; i++) {
 				char c = stringChar();
-				if (isWhitespace(c) || c == ':') {
+				if (c == '"') {
 					// Use the index before we fixup
-					reusableBuffer.append(buffer, index - i - 1, i);
+					expandBufferIfNeeded(i);
+					reusableBuffer.put(buffer, index - i - 1, i);
 					fixupAfterRawBufferRead();
 					return;
 				}
 				if (c == '\\' || (utf8 && (c & 0x80) != 0)) {
-					reusableBuffer.append(buffer, index - i - 1, i);
+					expandBufferIfNeeded(i);
+					reusableBuffer.put(buffer, index - i - 1, i);
 					index--;
 					break start;
 				}
-				if (c == '[' || c == ']' || c == '{' || c == '}' || c == ',') {
-					throw createParseException(null, "Invalid character in semi-string: " + c, false);
-				}
 			}
 
-			reusableBuffer.append(buffer, index - n, n);
+			expandBufferIfNeeded(n);
+			reusableBuffer.put(buffer, index - n, n);
 		}
 
-		outer:
-		while (true) {
+		outer: while (true) {
 			int n = ensureBuffer(BUFFER_ROOM);
 			if (n == 0)
 				throw createParseException(null, "String was not terminated before end of input", true);
 
 			int end = index + n;
 			while (index < end) {
+				// Ensure at least 1 char of space for upcoming output (common case). Escapes
+				// and
+				// UTF-8 multi-byte sequences will further ensure space as needed.
+				expandBufferIfNeeded(1);
 				char c = stringChar();
 
 				if (utf8 && (c & 0x80) != 0) {
@@ -470,52 +349,44 @@ void consumeTokenSemiString() throws JsonParserException {
 				}
 
 				switch (c) {
-				case ' ':
-				case '\n':
-				case '\r':
-				case '\t':
-				case ':':
-					fixupAfterRawBufferRead();
-					return;
-				case '[':
-				case ']':
-				case '{':
-				case '}':
-				case ',':
-					throw createParseException(null, "Invalid character in semi-string: " + c, false);
-				case '\\':
-					// Ensure that we have at least MAX_ESCAPE here in the buffer
-					if (end - index < MAX_ESCAPE) {
-						// Re-adjust the buffer end, unlikely path
-						n = ensureBuffer(MAX_ESCAPE);
-						end = index + n;
-						// Make sure that there's enough chars for a \\uXXXX escape
-						if (buffer[index] == 'u' && n < MAX_ESCAPE) {
-							index = bufferLength; // Reset index to last valid location
-							throw createParseException(null, "EOF encountered in the middle of a string escape", false);
+					case '\"':
+						fixupAfterRawBufferRead();
+						return;
+					case '\\':
+						// Ensure that we have at least MAX_ESCAPE here in the buffer
+						if (end - index < MAX_ESCAPE) {
+							// Re-adjust the buffer end, unlikely path
+							n = ensureBuffer(MAX_ESCAPE);
+							end = index + n;
+							// Make sure that there's enough chars for a \\uXXXX escape
+							if (buffer[index] == 'u' && n < MAX_ESCAPE) {
+								index = bufferLength; // Reset index to last valid location
+								throw createParseException(null,
+										"EOF encountered in the middle of a string escape",
+										false);
+							}
 						}
-					}
-					char escape = buffer[index++];
-					switch (escape) {
-						case 'b':
-							reusableBuffer.append('\b');
-							break;
-						case 'f':
-							reusableBuffer.append('\f');
-							break;
-						case 'n':
-							reusableBuffer.append('\n');
-							break;
-						case 'r':
-							reusableBuffer.append('\r');
-							break;
-						case 't':
-							reusableBuffer.append('\t');
+						char escape = buffer[index++];
+						switch (escape) {
+							case 'b':
+								reusableBuffer.put('\b');
+								break;
+							case 'f':
+								reusableBuffer.put('\f');
+								break;
+							case 'n':
+								reusableBuffer.put('\n');
+								break;
+							case 'r':
+								reusableBuffer.put('\r');
+								break;
+							case 't':
+								reusableBuffer.put('\t');
 								break;
 							case '"':
 							case '/':
 							case '\\':
-								reusableBuffer.append(escape);
+								reusableBuffer.put(escape);
 								break;
 							case 'u':
 								int escaped = 0;
@@ -530,25 +401,28 @@ void consumeTokenSemiString() throws JsonParserException {
 									} else if (digit >= 'a' && digit <= 'f') {
 										escaped |= (digit - 'a') + 10;
 									} else {
-										throw createParseException(null, "Expected unicode hex escape character: "
-												+ (char)digit + " (" + digit + ")", false);
+										throw createParseException(null,
+												"Expected unicode hex escape character: "
+												+ (char) digit + " (" + digit + ")", false);
 									}
 								}
 
-								reusableBuffer.append((char)escaped);
+								reusableBuffer.put((char) escaped);
 								break;
 							default:
 								throw createParseException(null, "Invalid escape: \\" + escape, false);
 						}
 						break;
 					default:
-						reusableBuffer.append(c);
+						reusableBuffer.put(c);
 				}
 			}
 
 			if (index > bufferLength) {
 				index = bufferLength; // Reset index to last valid location
-				throw createParseException(null, "EOF encountered in the middle of a string escape", false);
+				throw createParseException(null,
+						"EOF encountered in the middle of a string escape",
+						false);
 			}
 		}
 	}
@@ -557,75 +431,82 @@ void consumeTokenSemiString() throws JsonParserException {
 	private void consumeTokenStringUtf8Char(char c) throws JsonParserException {
 		ensureBuffer(5);
 
+		// Worst case (supplementary plane) decodes to a surrogate pair (2 chars)
+		expandBufferIfNeeded(2);
+
 		// Hand-UTF8-decoding
 		switch (c & 0xf0) {
-		case 0x80:
-		case 0x90:
-		case 0xa0:
-		case 0xb0:
-			throw createParseException(null,
-					"Illegal UTF-8 continuation byte: 0x" + Integer.toHexString(c & 0xff), false);
-		case 0xc0:
-			// Check for illegal C0 and C1 bytes
-			if ((c & 0xe) == 0)
-				throw createParseException(null, "Illegal UTF-8 byte: 0x" + Integer.toHexString(c & 0xff),
-						false);
-			// fall-through
-		case 0xd0:
-			c = (char)((c & 0x1f) << 6 | (buffer[index++] & 0x3f));
-			reusableBuffer.append(c);
-			utf8adjust++;
-			break;
-		case 0xe0:
-			c = (char)((c & 0x0f) << 12 | (buffer[index++] & 0x3f) << 6 | (buffer[index++] & 0x3f));
-			utf8adjust += 2;
-			// Check for illegally-encoded surrogate - http://unicode.org/faq/utf_bom.html#utf8-4
-			if ((c >= '\ud800' && c <= '\udbff') || (c >= '\udc00' && c <= '\udfff'))
-				throw createParseException(null, "Illegal UTF-8 codepoint: 0x" + Integer.toHexString(c),
-						false);
-			reusableBuffer.append(c);
-			break;
-		case 0xf0:
-			if ((c & 0xf) >= 5)
-				throw createParseException(null, "Illegal UTF-8 byte: 0x" + Integer.toHexString(c & 0xff),
-						false);
-
-			// Extended char
-			switch ((c & 0xc) >> 2) {
-			case 0:
-			case 1:
-				reusableBuffer.appendCodePoint((c & 7) << 18 | (buffer[index++] & 0x3f) << 12
-						| (buffer[index++] & 0x3f) << 6 | (buffer[index++] & 0x3f));
-				utf8adjust += 3;
-				break;
-			case 2:
-				// TODO: \uFFFD (replacement char)
-				int codepoint = (c & 3) << 24 | (buffer[index++] & 0x3f) << 18 | (buffer[index++] & 0x3f) << 12
-						| (buffer[index++] & 0x3f) << 6 | (buffer[index++] & 0x3f);
-				throw createParseException(null,
-						"Unable to represent codepoint 0x" + Integer.toHexString(codepoint)
-								+ " in a Java string", false);
-			case 3:
-				codepoint = (c & 1) << 30 | (buffer[index++] & 0x3f) << 24 | (buffer[index++] & 0x3f) << 18
-						| (buffer[index++] & 0x3f) << 12 | (buffer[index++] & 0x3f) << 6
-						| (buffer[index++] & 0x3f);
+			case 0x80:
+			case 0x90:
+			case 0xa0:
+			case 0xb0:
 				throw createParseException(null,
-						"Unable to represent codepoint 0x" + Integer.toHexString(codepoint)
-								+ " in a Java string", false);
+						"Illegal UTF-8 continuation byte: 0x" + Integer.toHexString(c & 0xff), false);
+			case 0xc0:
+				// Check for illegal C0 and C1 bytes
+				if ((c & 0xe) == 0)
+					throw createParseException(null, "Illegal UTF-8 byte: 0x" + Integer.toHexString(c & 0xff),
+							false);
+				// fall-through
+			case 0xd0:
+				c = (char) ((c & 0x1f) << 6 | (buffer[index++] & 0x3f));
+				reusableBuffer.put(c);
+				utf8adjust++;
+				break;
+			case 0xe0:
+				c = (char) ((c & 0x0f) << 12 | (buffer[index++] & 0x3f) << 6 | (buffer[index++] & 0x3f));
+				utf8adjust += 2;
+				// Check for illegally-encoded surrogate -
+				// http://unicode.org/faq/utf_bom.html#utf8-4
+				if ((c >= '\ud800' && c <= '\udbff') || (c >= '\udc00' && c <= '\udfff'))
+					throw createParseException(null, "Illegal UTF-8 codepoint: 0x" + Integer.toHexString(c),
+							false);
+				reusableBuffer.put(c);
+				break;
+			case 0xf0:
+				if ((c & 0xf) >= 5)
+					throw createParseException(null, "Illegal UTF-8 byte: 0x" + Integer.toHexString(c & 0xff),
+							false);
+
+				// Extended char
+				switch ((c & 0xc) >> 2) {
+					case 0:
+					case 1:
+						reusableBuffer.put(Character.toChars((c & 7) << 18 | (buffer[index++] & 0x3f) << 12
+								| (buffer[index++] & 0x3f) << 6 | (buffer[index++] & 0x3f)));
+						utf8adjust += 3;
+						break;
+					case 2:
+						// TODO: \uFFFD (replacement char)
+						int codepoint = (c & 3) << 24 | (buffer[index++] & 0x3f) << 18 | (buffer[index++] & 0x3f) << 12
+								| (buffer[index++] & 0x3f) << 6 | (buffer[index++] & 0x3f);
+						throw createParseException(null,
+								"Unable to represent codepoint 0x" + Integer.toHexString(codepoint)
+										+ " in a Java string",
+								false);
+					case 3:
+						codepoint = (c & 1) << 30 | (buffer[index++] & 0x3f) << 24 | (buffer[index++] & 0x3f) << 18
+								| (buffer[index++] & 0x3f) << 12 | (buffer[index++] & 0x3f) << 6
+								| (buffer[index++] & 0x3f);
+						throw createParseException(null,
+								"Unable to represent codepoint 0x" + Integer.toHexString(codepoint)
+										+ " in a Java string",
+								false);
+					default:
+						assert false : "Impossible";
+				}
+				break;
 			default:
-				assert false : "Impossible";
-			}
-			break;
-		default:
-			// Regular old byte
-			break;
+				// Regular old byte
+				break;
 		}
 		if (index > bufferLength)
 			throw createParseException(null, "UTF-8 codepoint was truncated", false);
 	}
 
 	/**
-	 * Advances a character, throwing if it is illegal in the context of a JSON string.
+	 * Advances a character, throwing if it is illegal in the context of a JSON
+	 * string.
 	 */
 	private char stringChar() throws JsonParserException {
 		char c = buffer[index++];
@@ -692,7 +573,8 @@ private int peekChar() {
 	}
 
 	/**
-	 * Ensures that there is enough room in the buffer to directly access the next N chars via buffer[].
+	 * Ensures that there is enough room in the buffer to directly access the next N
+	 * chars via buffer[].
 	 */
 	int ensureBuffer(int n) throws JsonParserException {
 		// We're good here
@@ -700,7 +582,8 @@ int ensureBuffer(int n) throws JsonParserException {
 			return n;
 		}
 
-		// Nope, we need to read more, but we also have to retain whatever buffer we have
+		// Nope, we need to read more, but we also have to retain whatever buffer we
+		// have
 		if (index > 0) {
 			charOffset += index;
 			bufferLength = bufferLength - index;
@@ -748,7 +631,7 @@ private int advanceChar() throws JsonParserException {
 
 		return c;
 	}
-	
+
 	int advanceCharFast() {
 		int c = buffer[index];
 		if (c == '\n') {
@@ -760,7 +643,7 @@ int advanceCharFast() {
 		index++;
 		return c;
 	}
-	
+
 	private void consumeWhitespace() throws JsonParserException {
 		int n;
 		do {
@@ -781,12 +664,13 @@ private void consumeWhitespace() throws JsonParserException {
 		} while (n > 0);
 		eof = true;
 	}
-	
+
 	/**
-	 * Consumes a token, first eating up any whitespace ahead of it. Note that number tokens are not necessarily valid
+	 * Consumes a token, first eating up any whitespace ahead of it. Note that
+	 * number tokens are not necessarily valid
 	 * numbers.
 	 */
-	int advanceToToken(boolean allowSemiString) throws JsonParserException {
+	int advanceToToken() throws JsonParserException {
 		int c = advanceChar();
 		while (isWhitespace(c))
 			c = advanceChar();
@@ -794,102 +678,69 @@ int advanceToToken(boolean allowSemiString) throws JsonParserException {
 		tokenCharPos = index + charOffset - rowPos - utf8adjust;
 		tokenCharOffset = charOffset + index;
 
-		int oldIndex = index;
 		int token;
 		switch (c) {
-		case -1:
-			return TOKEN_EOF;
-		case '[':
-			token = TOKEN_ARRAY_START;
-			break;
-		case ']':
-			token = TOKEN_ARRAY_END;
-			break;
-		case ',':
-			token = TOKEN_COMMA;
-			break;
-		case ':':
-			token = TOKEN_COLON;
-			break;
-		case '{':
-			token = TOKEN_OBJECT_START;
-			break;
-		case '}':
-			token = TOKEN_OBJECT_END;
-			break;
-		case 't':
-			try {
+			case -1:
+				return TOKEN_EOF;
+			case '[':
+				token = TOKEN_ARRAY_START;
+				break;
+			case ']':
+				token = TOKEN_ARRAY_END;
+				break;
+			case ',':
+				token = TOKEN_COMMA;
+				break;
+			case ':':
+				token = TOKEN_COLON;
+				break;
+			case '{':
+				token = TOKEN_OBJECT_START;
+				break;
+			case '}':
+				token = TOKEN_OBJECT_END;
+				break;
+			case 't':
 				consumeKeyword((char) c, JsonTokener.TRUE);
 				token = TOKEN_TRUE;
-			} catch (JsonParserException e) {
-				if (allowSemiString) {
-					index = oldIndex - 1;
-					consumeTokenSemiString();
-					token = TOKEN_SEMI_STRING;
-				} else throw e;
-			}
-			break;
-		case 'f':
-			try {
-				consumeKeyword((char)c, JsonTokener.FALSE);
+				break;
+			case 'f':
+				consumeKeyword((char) c, JsonTokener.FALSE);
 				token = TOKEN_FALSE;
-			} catch (JsonParserException e) {
-				if (allowSemiString) {
-					index = oldIndex - 1;
-					consumeTokenSemiString();
-					token = TOKEN_SEMI_STRING;
-				} else throw e;
-			}
-			break;
-		case 'n':
-			try {
-				consumeKeyword((char)c, JsonTokener.NULL);
+				break;
+			case 'n':
+				consumeKeyword((char) c, JsonTokener.NULL);
 				token = TOKEN_NULL;
-			} catch (JsonParserException e) {
-				if (allowSemiString) {
-					index = oldIndex - 1;
-					consumeTokenSemiString();
-					token = TOKEN_SEMI_STRING;
-				} else throw e;
-			}
-			break;
-		case '"':
-		case '\'':
-			consumeTokenString(c);
-			token = TOKEN_STRING;
-			break;
-		case '-':
-		case '0':
-		case '1':
-		case '2':
-		case '3':
-		case '4':
-		case '5':
-		case '6':
-		case '7':
-		case '8':
-		case '9':
-			consumeTokenNumber((char)c);
-			token = TOKEN_NUMBER;
-			break;
-		case '+':
-		case '.':
-			throw createParseException(null, "Numbers may not start with '" + (char)c + "'", true);
-		default:
-			if (allowSemiString) {
-				index--;
-				consumeTokenSemiString();
-				token = TOKEN_SEMI_STRING;
 				break;
-			} else {
+			case '\"':
+				consumeTokenString();
+				token = TOKEN_STRING;
+				break;
+			case '-':
+			case '0':
+			case '1':
+			case '2':
+			case '3':
+			case '4':
+			case '5':
+			case '6':
+			case '7':
+			case '8':
+			case '9':
+				consumeTokenNumber((char) c);
+				token = TOKEN_NUMBER;
+				break;
+			case '+':
+			case '.':
+				throw createParseException(null, "Numbers may not start with '" + (char) c + "'", true);
+			default:
 				if (isAsciiLetter(c))
-					throw createHelpfulException((char)c, null, 0);
+					throw createHelpfulException((char) c, null, 0);
 
-				throw createParseException(null, "Unexpected character: " + (char)c, true);
-			}
+				throw createParseException(null, "Unexpected character: " + (char) c, true);
 		}
-		
-//		consumeWhitespace();
+
+		// consumeWhitespace();
 		return token;
 	}
 
@@ -908,6 +759,18 @@ void fixupAfterRawBufferRead() throws JsonParserException {
 			eof = refillBuffer();
 	}
 
+	private void expandBufferIfNeeded(int size) {
+		if (reusableBuffer.remaining() < size) {
+			int oldPos = reusableBuffer.position();
+			int increment = Math.max(512, size - reusableBuffer.remaining());
+			CharBuffer newBuffer = CharBuffer.allocate(reusableBuffer.capacity() + increment);
+			reusableBuffer.flip(); // position -> 0, limit -> oldPos
+			newBuffer.put(reusableBuffer); // copy all existing data
+			reusableBuffer = newBuffer;
+			reusableBuffer.position(oldPos); // restore write position at end
+		}
+	}
+
 	/**
 	 * Throws a helpful exception based on the current alphanumeric token.
 	 */
@@ -919,14 +782,30 @@ JsonParserException createHelpfulException(char first, char[] expected, int fail
 
 		// Consume the whole pseudo-token to make a better error message
 		while (isAsciiLetter(peekChar()) && errorToken.length() < 15)
-			errorToken.append((char)advanceChar());
+			errorToken.append((char) advanceChar());
 
 		return createParseException(null, "Unexpected token '" + errorToken + "'"
 				+ (expected == null ? "" : ". Did you mean '" + first + new String(expected) + "'?"), true);
 	}
 
 	/**
-	 * Creates a {@link JsonParserException} and fills it from the current line and char position.
+	 * Releases resources used by this JsonTokener. Should be called when done
+	 * tokenizing.
+	 */
+	@Override
+	public void close() throws IOException {
+		if (reusableBuffer != null) {
+			CharBufferPool.release(reusableBuffer);
+			reusableBuffer = null;
+		}
+		if (reader != null) {
+			reader.close();
+		}
+	}
+
+	/**
+	 * Creates a {@link JsonParserException} and fills it from the current line and
+	 * char position.
 	 */
 	JsonParserException createParseException(Exception e, String message, boolean tokenPos) {
 		if (tokenPos)
diff --git a/src/main/java/com/grack/nanojson/JsonWriter.java b/src/main/java/com/grack/nanojson/JsonWriter.java
index 25d1dec..b9af209 100644
--- a/src/main/java/com/grack/nanojson/JsonWriter.java
+++ b/src/main/java/com/grack/nanojson/JsonWriter.java
@@ -151,7 +151,7 @@ public JsonAppendableWriter on(OutputStream out) {
 	 * 
 	 */
 	//@formatter:on
-	public static JsonWriterContext indent(String indent) {
+	public static JsonWriter.JsonWriterContext indent(String indent) {
 		if (indent == null) {
 			throw new IllegalArgumentException("indent must be non-null");
 		}
diff --git a/src/main/java/com/grack/nanojson/JsonWriterBase.java b/src/main/java/com/grack/nanojson/JsonWriterBase.java
index 350737e..19cdfba 100644
--- a/src/main/java/com/grack/nanojson/JsonWriterBase.java
+++ b/src/main/java/com/grack/nanojson/JsonWriterBase.java
@@ -26,9 +26,9 @@
  * Internal class that handles emitting to an {@link Appendable}. Users only see
  * the public subclasses, {@link JsonStringWriter} and
  * {@link JsonAppendableWriter}.
- * 
+ *
  * @param 
- *            A subclass of {@link JsonWriterBase}.
+ *               A subclass of {@link JsonWriterBase}.
  */
 class JsonWriterBase> implements
 		JsonSink {
@@ -50,6 +50,7 @@ class JsonWriterBase> implements
 	private int stateIndex = 0;
 	private boolean first = true;
 	private boolean inObject;
+	private String pendingKey;
 
 	/**
 	 * Sequence to use for indenting.
@@ -123,8 +124,9 @@ public SELF object(String key, Map map) {
 			Object o = entry.getValue();
 			if (!(entry.getKey() instanceof String))
 				throw new JsonWriterException("Invalid key type for map: "
-						+ (entry.getKey() == null ? "null" : entry.getKey()
-								.getClass()));
+						+ (entry.getKey() == null ? "null"
+								: entry.getKey()
+										.getClass()));
 			String k = (String) entry.getKey();
 			value(k, o);
 		}
@@ -152,6 +154,8 @@ public SELF value(Object o) {
 			return nul();
 		else if (o instanceof String)
 			return value((String) o);
+		else if (o instanceof LazyString)
+			return value(o.toString());
 		else if (o instanceof Number)
 			return value(((Number) o));
 		else if (o instanceof Boolean)
@@ -166,7 +170,9 @@ else if (o.getClass().isArray()) {
 			for (int i = 0; i < length; i++)
 				value(Array.get(o, i));
 			return end();
-		} else
+		} else if (o instanceof JsonConvertible)
+			return value(((JsonConvertible) o).toJsonValue());
+		else
 			throw new JsonWriterException("Unable to handle type: "
 					+ o.getClass());
 	}
@@ -177,6 +183,8 @@ public SELF value(String key, Object o) {
 			return nul(key);
 		else if (o instanceof String)
 			return value(key, (String) o);
+		else if (o instanceof LazyString)
+			return value(key, o.toString());
 		else if (o instanceof Number)
 			return value(key, (Number) o);
 		else if (o instanceof Boolean)
@@ -191,7 +199,9 @@ else if (o.getClass().isArray()) {
 			for (int i = 0; i < length; i++)
 				value(Array.get(o, i));
 			return end();
-		} else
+		} else if (o instanceof JsonConvertible)
+			return value(key, ((JsonConvertible) o).toJsonValue());
+		else
 			throw new JsonWriterException("Unable to handle type: "
 					+ o.getClass());
 	}
@@ -243,7 +253,7 @@ public SELF value(float d) {
 	@Override
 	public SELF value(Number n) {
 		preValue();
-		if (n == null)
+		if (n == null || nullish(n))
 			raw(NULL);
 		else
 			raw(n.toString());
@@ -372,12 +382,25 @@ public SELF end() {
 		return castThis();
 	}
 
+	@Override
+	public SELF key(String key) {
+		if (key == null)
+			throw new NullPointerException("key");
+		if (pendingKey != null)
+			throw new JsonWriterException(
+					"Invalid call to emit a key immediately after emitting a key");
+		pendingKey = key;
+		return castThis();
+	}
+
 	/**
 	 * Ensures that the object is in the finished state.
-	 * 
+	 *
 	 * @throws JsonWriterException
-	 *             if the written JSON is not properly balanced, ie: all arrays
-	 *             and objects that were started have been properly ended.
+	 *                             if the written JSON is not properly balanced, ie:
+	 *                             all arrays
+	 *                             and objects that were started have been properly
+	 *                             ended.
 	 */
 	protected void doneInternal() {
 		if (stateIndex > 0)
@@ -434,7 +457,7 @@ private void raw(char c) {
 		if (utf8) {
 			if (bo + 1 > BUFFER_SIZE)
 				flush();
-			bb[bo++] = (byte)c;
+			bb[bo++] = (byte) c;
 		} else {
 			buffer.append(c);
 			if (buffer.length() > BUFFER_SIZE) {
@@ -472,6 +495,12 @@ private void pre() {
 	}
 
 	private void preValue() {
+		if (pendingKey != null) {
+			String key = pendingKey;
+			pendingKey = null;
+			preValue(key);
+			return;
+		}
 		if (inObject)
 			throw new JsonWriterException(
 					"Invalid call to emit a keyless value while writing an object");
@@ -483,6 +512,9 @@ private void preValue(String key) {
 		if (!inObject)
 			throw new JsonWriterException(
 					"Invalid call to emit a key value while not writing an object");
+		if (pendingKey != null)
+			throw new JsonWriterException(
+					"Invalid call to emit a key value immediately after emitting a key");
 
 		pre();
 
@@ -505,82 +537,106 @@ private void emitStringValue(String s) {
 			c = s.charAt(i);
 
 			switch (c) {
-			case '\\':
-			case '"':
-				raw('\\');
-				raw(c);
-				break;
-			case '/':
-				// Special case to ensure that  doesn't appear in JSON
-				// output
-				if (b == '<')
+				case '\\':
+				case '"':
 					raw('\\');
-				raw(c);
-				break;
-			case '\b':
-				raw("\\b");
-				break;
-			case '\t':
-				raw("\\t");
-				break;
-			case '\n':
-				raw("\\n");
-				break;
-			case '\f':
-				raw("\\f");
-				break;
-			case '\r':
-				raw("\\r");
-				break;
-			default:
-				if (shouldBeEscaped(c)) {
-					if (c < 0x100) {
-						raw(UNICODE_SMALL);
-						raw(HEX[(c >> 4) & 0xf]);
-						raw(HEX[c & 0xf]);
+					raw(c);
+					break;
+				case '/':
+					// Special case to ensure that  doesn't appear in JSON
+					// output
+					if (b == '<')
+						raw('\\');
+					raw(c);
+					break;
+				case '\b':
+					raw("\\b");
+					break;
+				case '\t':
+					raw("\\t");
+					break;
+				case '\n':
+					raw("\\n");
+					break;
+				case '\f':
+					raw("\\f");
+					break;
+				case '\r':
+					raw("\\r");
+					break;
+				default:
+					if (shouldBeEscaped(c)) {
+						if (c < 0x100) {
+							raw(UNICODE_SMALL);
+							raw(HEX[(c >> 4) & 0xf]);
+							raw(HEX[c & 0xf]);
+						} else {
+							raw(UNICODE_LARGE);
+							raw(HEX[(c >> 12) & 0xf]);
+							raw(HEX[(c >> 8) & 0xf]);
+							raw(HEX[(c >> 4) & 0xf]);
+							raw(HEX[c & 0xf]);
+						}
 					} else {
-						raw(UNICODE_LARGE);
-						raw(HEX[(c >> 12) & 0xf]);
-						raw(HEX[(c >> 8) & 0xf]);
-						raw(HEX[(c >> 4) & 0xf]);
-						raw(HEX[c & 0xf]);
-					}
-				} else {
-					if (utf8) {
-						if (bo + 4 > BUFFER_SIZE) // 4 is the max char size
-							flush();
-						if (c < 0x80) {
-							bb[bo++] = (byte) c;
-						} else if (c < 0x800) {
-							bb[bo++] = (byte) (0xc0 | c >> 6);
-							bb[bo++] = (byte) (0x80 | c & 0x3f);
-						} else if (c < 0xd800) {
-							bb[bo++] = (byte) (0xe0 | c >> 12);
-							bb[bo++] = (byte) (0x80 | (c >> 6) & 0x3f);
-							bb[bo++] = (byte) (0x80 | c & 0x3f);
-						} else if (c < 0xdfff) {
-							// TODO: bad surrogates
-							i++;
-
-							int fc = Character.toCodePoint(c, s.charAt(i));
-							if (fc < 0x1fffff) {
-								bb[bo++] = (byte) (0xf0 | fc >> 18);
-								bb[bo++] = (byte) (0x80 | (fc >> 12) & 0x3f);
-								bb[bo++] = (byte) (0x80 | (fc >> 6) & 0x3f);
-								bb[bo++] = (byte) (0x80 | fc & 0x3f);
+						if (utf8) {
+							// Ensure space for the largest possible UTF-8 sequence (4 bytes) before
+							// encoding. Even if this char ultimately encodes to 1,2 or 3 bytes,
+							// reserving for 4 keeps logic simple and guarantees we never start a
+							// multi-byte sequence that would be split across a flush.
+							if (bo + 4 > BUFFER_SIZE) // 4 is the max UTF-8 byte length for a single Unicode scalar
+														// value
+								flush();
+							if (c < 0x80) {
+								bb[bo++] = (byte) c;
+							} else if (c < 0x800) {
+								bb[bo++] = (byte) (0xc0 | c >> 6);
+								bb[bo++] = (byte) (0x80 | c & 0x3f);
+							} else if (c < 0xd800) {
+								bb[bo++] = (byte) (0xe0 | c >> 12);
+								bb[bo++] = (byte) (0x80 | (c >> 6) & 0x3f);
+								bb[bo++] = (byte) (0x80 | c & 0x3f);
+							} else if (Character.isHighSurrogate(c)) {
+								// Surrogate pair handling (supplementary plane character)
+								// We have a high surrogate; must be followed by a low surrogate to form a valid
+								// code point.
+								if (i + 1 >= s.length()) {
+									throw new JsonWriterException("Invalid high surrogate at end of string");
+								}
+								char lowSurrogate = s.charAt(i + 1);
+								if (!Character.isLowSurrogate(lowSurrogate)) {
+									throw new JsonWriterException("Invalid surrogate pair: "
+											+ "high surrogate not followed by low surrogate");
+								}
+								// Need 4 bytes for any supplementary code point in UTF-8. Flush first if
+								// insufficient space
+								// so the 4-byte sequence is never split across buffers.
+								if (bo + 4 > BUFFER_SIZE)
+									flush();
+								i++; // consume the low surrogate
+								int fc = Character.toCodePoint(c, lowSurrogate); // full scalar value
+								// Unicode scalar values are defined only up to U+10FFFF
+								// (exclusive upper bound 0x110000).
+								if (fc < 0x110000) {
+									bb[bo++] = (byte) (0xf0 | (fc >> 18));
+									bb[bo++] = (byte) (0x80 | (fc >> 12) & 0x3f);
+									bb[bo++] = (byte) (0x80 | (fc >> 6) & 0x3f);
+									bb[bo++] = (byte) (0x80 | fc & 0x3f);
+								} else {
+									throw new JsonWriterException(
+											"Unable to encode character 0x" + Integer.toHexString(fc));
+								}
+							} else if (Character.isLowSurrogate(c)) {
+								throw new JsonWriterException(
+										"Invalid low surrogate without preceding high surrogate");
 							} else {
-								throw new JsonWriterException("Unable to encode character 0x" 
-										+ Integer.toHexString(fc));
+								bb[bo++] = (byte) (0xe0 | c >> 12);
+								bb[bo++] = (byte) (0x80 | (c >> 6) & 0x3f);
+								bb[bo++] = (byte) (0x80 | c & 0x3f);
 							}
 						} else {
-							bb[bo++] = (byte) (0xe0 | c >> 12);
-							bb[bo++] = (byte) (0x80 | (c >> 6) & 0x3f);
-							bb[bo++] = (byte) (0x80 | c & 0x3f);
+							raw(c);
 						}
-					} else {
-						raw(c);
 					}
-				}
 			}
 		}
 
@@ -594,4 +650,25 @@ private boolean shouldBeEscaped(char c) {
 		return c < ' ' || (c >= '\u0080' && c < '\u00a0')
 				|| (c >= '\u2000' && c < '\u2100');
 	}
+
+	/**
+	 * Returns true if the number becomes null when converted to JSON. json.org spec
+	 * does not specify
+	 * NaN or Infinity as numbers, and modern JavaScript engines convert them to
+	 * null.
+	 *
+	 * @param n a number
+	 * @return true if the number is nullish.
+	 */
+	private boolean nullish(Number n) {
+		if (n instanceof Double) {
+			Double d = (Double) n;
+			return d.isNaN() || d.isInfinite();
+		}
+		if (n instanceof Float) {
+			Float f = (Float) n;
+			return f.isNaN() || f.isInfinite();
+		}
+		return false;
+	}
 }
diff --git a/src/main/java/com/grack/nanojson/LazyString.java b/src/main/java/com/grack/nanojson/LazyString.java
new file mode 100644
index 0000000..0b70535
--- /dev/null
+++ b/src/main/java/com/grack/nanojson/LazyString.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2011 The nanojson Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.grack.nanojson;
+
+import java.util.Arrays;
+
+public class LazyString implements CharSequence {
+
+    private final int length;
+
+    private char[] value;
+    private String stringValue;
+
+    private final Object lock = new Object();
+
+    public LazyString(char[] value) {
+        this.value = value;
+        this.length = value.length;
+    }
+
+    public LazyString(String value) {
+        this.stringValue = value;
+        this.length = value.length();
+    }
+
+    @Override
+    public int length() {
+        return length;
+    }
+
+    @Override
+    public char charAt(int index) {
+        String str = stringValue; // Local ref copy to avoid race
+        if (str != null) {
+            return str.charAt(index);
+        }
+        char[] arr = value; // Local ref copy to avoid race
+        if (arr != null) {
+            return arr[index];
+        }
+        // Fallback if value was nullified
+        return toString().charAt(index);
+    }
+
+    @Override
+    public CharSequence subSequence(int start, int end) {
+        String str = stringValue; // Local ref copy to avoid race
+        if (str != null) {
+            return str.subSequence(start, end);
+        }
+        char[] arr = value; // Local ref copy to avoid race
+        if (arr != null) {
+            return new LazyString(Arrays.copyOfRange(arr, start, end));
+        }
+        // Fallback to string-based subsequence
+        return toString().subSequence(start, end);
+    }
+
+    public String toString() {
+        if (stringValue != null) {
+            return stringValue;
+        }
+        synchronized (lock) {
+            if (stringValue == null) {
+                stringValue = new String(value);
+            }
+            value = null; // Clear the char array to save memory
+        }
+        return stringValue;
+    }
+
+    @Override
+    public int hashCode() {
+        String str = stringValue; // Local ref copy to avoid race
+        if (str != null) {
+            return str.hashCode();
+        }
+        char[] arr = value; // Local ref copy to avoid race
+        if (arr != null) {
+            return Arrays.hashCode(arr);
+        }
+        return toString().hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (!(obj instanceof CharSequence))
+            return false;
+        if (obj instanceof LazyString) {
+            LazyString other = (LazyString) obj;
+            String str = stringValue; // Local ref copy to avoid race
+            String otherStr = other.stringValue; // Local ref copy to avoid race
+            if (str != null && otherStr != null) {
+                return str.equals(otherStr);
+            } else if (str != null) {
+                return str.contentEquals((CharSequence) other);
+            } else if (otherStr != null) {
+                return otherStr.contentEquals((CharSequence) this);
+            }
+            // Both are LazyString without stringValue
+            char[] arr = value; // Local ref copy to avoid race
+            char[] otherArr = other.value; // Local ref copy to avoid race
+            if (arr != null && otherArr != null) {
+                return Arrays.equals(arr, otherArr);
+            }
+            // Fallback to string comparison
+            return toString().equals(other.toString());
+        }
+        return toString().contentEquals((CharSequence) obj);
+    }
+}
diff --git a/src/test/java/com/grack/nanojson/JsonBuilderTest.java b/src/test/java/com/grack/nanojson/JsonBuilderTest.java
new file mode 100644
index 0000000..81e5d65
--- /dev/null
+++ b/src/test/java/com/grack/nanojson/JsonBuilderTest.java
@@ -0,0 +1,29 @@
+package com.grack.nanojson;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+class JsonBuilderTest {
+	@Test
+	void failureKeyInArray() {
+		assertThrows(JsonWriterException.class, () ->
+			new JsonBuilder<>(new JsonArray()).key("a"));
+	}
+
+	@Test
+	void failureKeyWhileKeyPending() {
+		assertThrows(JsonWriterException.class, () ->
+			new JsonBuilder<>(new JsonObject()).key("a").key("b"));
+	}
+
+	@Test
+	void separateKeyWriting() {
+		JsonObject actual = new JsonBuilder<>(new JsonObject()).key("a").value(1).key("b").value(2).done();
+		JsonObject expected = new JsonObject();
+		expected.put("a", 1);
+		expected.put("b", 2);
+		assertEquals(expected, actual);
+	}
+}
diff --git a/src/test/java/com/grack/nanojson/JsonNumberTest.java b/src/test/java/com/grack/nanojson/JsonNumberTest.java
index 45d2d55..3ff4475 100644
--- a/src/test/java/com/grack/nanojson/JsonNumberTest.java
+++ b/src/test/java/com/grack/nanojson/JsonNumberTest.java
@@ -15,111 +15,113 @@
  */
 package com.grack.nanojson;
 
-import static org.junit.Assert.assertEquals;
+import org.junit.jupiter.api.Test;
 
 import java.math.BigInteger;
 
-import org.junit.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 /**
  * Attempts to test that numbers are correctly round-tripped.
  */
-public class JsonNumberTest {
-	// CHECKSTYLE_OFF: MagicNumber
-	// CHECKSTYLE_OFF: JavadocMethod
-	@Test
-	public void testBasicNumberRead() throws JsonParserException {
-		JsonArray array = JsonParser.array().from("[1, 1.0, 1.00]");
-		assertEquals(Integer.class, array.get(0).getClass());
-		assertEquals(Double.class, array.get(1).getClass());
-		assertEquals(Double.class, array.get(2).getClass());
-	}
+class JsonNumberTest {
+    // CHECKSTYLE_OFF: MagicNumber
+    // CHECKSTYLE_OFF: JavadocMethod
+    @Test
+    void basicNumberRead() throws JsonParserException {
+        JsonArray array = JsonParser.array().from("[1, 1.0, 1.00]");
+        assertEquals(1, ((Number) array.get(0)).intValue());
+        assertEquals(1.0, ((Number) array.get(1)).doubleValue(), 0.0);
+        assertEquals(1.0, ((Number) array.get(2)).doubleValue(), 0.0);
+    }
 
-	@Test
-	public void testBasicNumberWrite() {
-		JsonArray array = JsonArray.from(1, 1.0, 1.0f);
-		assertEquals("[1,1.0,1.0]", JsonWriter.string().array(array).done());
-	}
+    @Test
+    void basicNumberWrite() {
+        JsonArray array = JsonArray.from(1, 1.0, 1.0f);
+        assertEquals("[1,1.0,1.0]", JsonWriter.string().array(array).done());
+    }
 
-	@Test
-	public void testLargeIntRead() throws JsonParserException {
-		JsonArray array = JsonParser.array().from("[-300000000,300000000]");
-		assertEquals(Integer.class, array.get(0).getClass());
-		assertEquals(-300000000, array.get(0));
-		assertEquals(Integer.class, array.get(1).getClass());
-		assertEquals(300000000, array.get(1));
-	}
+    @Test
+    void largeIntRead() throws JsonParserException {
+        JsonArray array = JsonParser.array().from("[-300000000,300000000]");
+        assertEquals(-300000000, ((Number) array.get(0)).intValue());
+        assertEquals(300000000, ((Number) array.get(1)).intValue());
+    }
 
-	@Test
-	public void testLargeIntWrite() {
-		JsonArray array = JsonArray.from(-300000000, 300000000);
-		assertEquals("[-300000000,300000000]", JsonWriter.string().array(array)
-				.done());
-	}
+    @Test
+    void largeIntWrite() {
+        JsonArray array = JsonArray.from(-300000000, 300000000);
+        assertEquals("[-300000000,300000000]", JsonWriter.string().array(array)
+                .done());
+    }
 
-	@Test
-	public void testLongRead() throws JsonParserException {
-		JsonArray array = JsonParser.array().from("[-3000000000,3000000000]");
-		assertEquals(Long.class, array.get(0).getClass());
-		assertEquals(-3000000000L, array.get(0));
-		assertEquals(Long.class, array.get(1).getClass());
-		assertEquals(3000000000L, array.get(1));
-	}
+    @Test
+    void longRead() throws JsonParserException {
+        JsonArray array = JsonParser.array().from("[-3000000000,3000000000]");
+        assertEquals(-3000000000L, ((Number) array.get(0)).longValue());
+        assertEquals(3000000000L, ((Number) array.get(1)).longValue());
+    }
 
-	@Test
-	public void testLongWrite() {
-		JsonArray array = JsonArray.from(1L, -3000000000L, 3000000000L);
-		assertEquals("[1,-3000000000,3000000000]",
-				JsonWriter.string().array(array).done());
-	}
+    @Test
+    void longWrite() {
+        JsonArray array = JsonArray.from(1L, -3000000000L, 3000000000L);
+        assertEquals("[1,-3000000000,3000000000]",
+                JsonWriter.string().array(array).done());
+    }
 
-	@Test
-	public void testBigIntRead() throws JsonParserException {
-		JsonArray array = JsonParser.array().from(
-				"[-30000000000000000000,30000000000000000000]");
-		assertEquals(BigInteger.class, array.get(0).getClass());
-		assertEquals(new BigInteger("-30000000000000000000"), array.get(0));
-		assertEquals(BigInteger.class, array.get(1).getClass());
-		assertEquals(new BigInteger("30000000000000000000"), array.get(1));
-	}
+    @Test
+    void bigIntRead() throws JsonParserException {
+        JsonArray array = JsonParser.array().from(
+                "[-30000000000000000000,30000000000000000000]");
+        // cast to ensure it's a number
+        assertEquals("-30000000000000000000", ((Number) array.get(0)).toString());
+        assertEquals("30000000000000000000", ((Number) array.get(1)).toString());
+    }
 
-	@Test
-	public void testBigIntWrite() {
-		JsonArray array = JsonArray.from(BigInteger.ONE, new BigInteger(
-				"-30000000000000000000"),
-				new BigInteger("30000000000000000000"));
-		assertEquals("[1,-30000000000000000000,30000000000000000000]",
-				JsonWriter.string().array(array).done());
-	}
+    @Test
+    void bigIntWrite() {
+        JsonArray array = JsonArray.from(BigInteger.ONE, new BigInteger(
+                        "-30000000000000000000"),
+                new BigInteger("30000000000000000000"));
+        assertEquals("[1,-30000000000000000000,30000000000000000000]",
+                JsonWriter.string().array(array).done());
+    }
 
-	/**
-	 * Tests a bug where longs were silently truncated to floats.
-	 */
-	@Test
-	public void testLongBuilder() {
-		JsonObject o = JsonObject.builder().value("long", 0xffffffffffffL)
-				.done();
-		assertEquals(0xffffffffffffL, o.getNumber("long").longValue());
-	}
+    /**
+     * Tests a bug where longs were silently truncated to floats.
+     */
+    @Test
+    void longBuilder() {
+        JsonObject o = JsonObject.builder().value("long", 0xffffffffffffL)
+                .done();
+        assertEquals(0xffffffffffffL, o.getNumber("long").longValue());
+    }
 
-	/**
-	 * Test around the edges of the integral types.
-	 */
-	@Test
-	public void testAroundEdges() throws JsonParserException {
-		JsonArray array = JsonArray.from(Integer.MAX_VALUE,
-				((long) Integer.MAX_VALUE) + 1, Integer.MIN_VALUE,
-				((long) Integer.MIN_VALUE) - 1, Long.MAX_VALUE, BigInteger
-						.valueOf(Long.MAX_VALUE).add(BigInteger.ONE),
-				Long.MIN_VALUE,
-				BigInteger.valueOf(Long.MIN_VALUE).subtract(BigInteger.ONE));
-		String json = JsonWriter.string().array(array).done();
-		assertEquals(
-				"[2147483647,2147483648,-2147483648,-2147483649,9223372036854775807,"
-						+ "9223372036854775808,-9223372036854775808,-9223372036854775809]",
-				json);
-		JsonArray array2 = JsonParser.array().from(json);
-		String json2 = JsonWriter.string().array(array2).done();
-		assertEquals(json, json2);
-	}
+    /**
+     * Test around the edges of the integral types.
+     */
+    @Test
+    void aroundEdges() throws JsonParserException {
+        JsonArray array = JsonArray.from(Integer.MAX_VALUE,
+                ((long) Integer.MAX_VALUE) + 1, Integer.MIN_VALUE,
+                ((long) Integer.MIN_VALUE) - 1, Long.MAX_VALUE, BigInteger
+                        .valueOf(Long.MAX_VALUE).add(BigInteger.ONE),
+                Long.MIN_VALUE,
+                BigInteger.valueOf(Long.MIN_VALUE).subtract(BigInteger.ONE));
+        String json = JsonWriter.string().array(array).done();
+        assertEquals(
+                "[2147483647,2147483648,-2147483648,-2147483649,9223372036854775807,"
+                        + "9223372036854775808,-9223372036854775808,-9223372036854775809]",
+                json);
+        JsonArray array2 = JsonParser.array().from(json);
+        String json2 = JsonWriter.string().array(array2).done();
+        assertEquals(json, json2);
+    }
+
+    @Test
+    void trailingDecimalLazy() throws JsonParserException {
+        Object value = JsonParser.any().withLazyNumbers().from("1.000");
+        String json = JsonWriter.string().value(value).done();
+        assertEquals("1.000", json);
+    }
 }
diff --git a/src/test/java/com/grack/nanojson/JsonParserTest.java b/src/test/java/com/grack/nanojson/JsonParserTest.java
index 7b89b8f..dbbf3f5 100644
--- a/src/test/java/com/grack/nanojson/JsonParserTest.java
+++ b/src/test/java/com/grack/nanojson/JsonParserTest.java
@@ -1,12 +1,12 @@
 /*
  * Copyright 2011 The nanojson Authors
- * 
+ *
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not
  * use this file except in compliance with the License. You may obtain a copy of
  * the License at
- * 
+ *
  * http://www.apache.org/licenses/LICENSE-2.0
- * 
+ *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
@@ -15,29 +15,28 @@
  */
 package com.grack.nanojson;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.math.BigInteger;
 import java.nio.charset.Charset;
 import java.util.Arrays;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
 
 /**
  * Test for {@link JsonParser}.
  */
-public class JsonParserTest {
+class JsonParserTest {
 	private static final Charset UTF8;
 
 	static {
@@ -48,88 +47,85 @@ public class JsonParserTest {
 	// CHECKSTYLE_OFF: JavadocMethod
 	// CHECKSTYLE_OFF: EmptyBlock
 	@Test
-	public void testWhitespace() throws JsonParserException {
+	void whitespace() throws JsonParserException {
 		assertEquals(JsonObject.class,
 				JsonParser.object().from(" \t\r\n  { \t\r\n \"abc\"   \t\r\n : \t\r\n  1 \t\r\n  }  \t\r\n   ")
 						.getClass());
 		assertEquals("{}", JsonParser.object().from("{}").toString());
 	}
-	
+
 	@Test
-	public void testWhitespaceSimpler() throws JsonParserException {
+	void whitespaceSimpler() throws JsonParserException {
 		assertEquals(JsonObject.class,
 				JsonParser.object().from(" {} ")
 						.getClass());
 	}
 
-
 	@Test
-	public void testWriterOutput() throws JsonParserException {
+	void writerOutput() throws JsonParserException {
 		//@formatter:off
 		String json = JsonWriter.string()
-				.object()
-					.object("a")
-						.array("b")
-							.object()
-								.value("a", 1)
-								.value("b", 2)
-							.end()
-							.object()
-								.value("c", 1.0)
-								.value("d", 2.0)
-							.end()
+			.object()
+				.object("a")
+					.array("b")
+						.object()
+							.value("a", 1)
+							.value("b", 2)
+						.end()
+						.object()
+							.value("c", 1.0)
+							.value("d", 2.0)
 						.end()
-						.value("c", JsonArray.from("v0", "v1", "v2"))
 					.end()
+					.value("c", JsonArray.from("v0", "v1", "v2"))
 				.end()
-			.done();
+			.end()
+		.done();
 		//@formatter:on
-		
-		// Just make sure it can be read - don't validate
-		JsonParser.object().from(json);
+		JsonParser.object().from(json); // ensure parseable
 	}
-	
+
 	@Test
-	public void testEmptyObject() throws JsonParserException {
+	void emptyObject() throws JsonParserException {
 		assertEquals(JsonObject.class, JsonParser.object().from("{}").getClass());
 		assertEquals("{}", JsonParser.object().from("{}").toString());
 	}
 
 	@Test
-	public void testObjectOneElement() throws JsonParserException {
+	void objectOneElement() throws JsonParserException {
 		assertEquals(JsonObject.class, JsonParser.object().from("{\"a\":1}").getClass());
 		assertEquals("{a=1}", JsonParser.object().from("{\"a\":1}").toString());
 	}
 
 	@Test
-	public void testObjectTwoElements() throws JsonParserException {
+	void objectTwoElements() throws JsonParserException {
 		JsonObject obj = JsonParser.object().from("{\"a\":1,\"B\":1}");
 		assertEquals(JsonObject.class, obj.getClass());
-		assertEquals(1, obj.get("B"));
-		assertEquals(1, obj.get("a"));
+		assertEquals(1, obj.getInt("B"));
+		assertEquals(1, obj.getInt("a"));
 		assertEquals(2, obj.size());
 	}
 
 	@Test
-	public void testEmptyArray() throws JsonParserException {
+	void emptyArray() throws JsonParserException {
 		assertEquals(JsonArray.class, JsonParser.array().from("[]").getClass());
 		assertEquals("[]", JsonParser.array().from("[]").toString());
 	}
 
 	@Test
-	public void testArrayOneElement() throws JsonParserException {
+	void arrayOneElement() throws JsonParserException {
 		assertEquals(JsonArray.class, JsonParser.array().from("[1]").getClass());
 		assertEquals("[1]", JsonParser.array().from("[1]").toString());
 	}
 
 	@Test
-	public void testArrayTwoElements() throws JsonParserException {
+	void arrayTwoElements() throws JsonParserException {
 		assertEquals(JsonArray.class, JsonParser.array().from("[1,1]").getClass());
 		assertEquals("[1, 1]", JsonParser.array().from("[1,1]").toString());
 	}
 
 	@Test
-	public void testBasicTypes() throws JsonParserException {
+	void basicTypes() throws JsonParserException {
 		assertEquals("true", JsonParser.any().from("true").toString());
 		assertEquals("false", JsonParser.any().from("false").toString());
 		assertNull(JsonParser.any().from("null"));
@@ -140,9 +136,9 @@ public void testBasicTypes() throws JsonParserException {
 	}
 
 	@Test
-	public void testArrayWithEverything() throws JsonParserException {
+	void arrayWithEverything() throws JsonParserException {
 		JsonArray a = JsonParser.array().from("[1, -1.0e6, \"abc\", [1,2,3], {\"abc\":123}, true, false]");
-		assertEquals("[1, -1000000.0, abc, [1, 2, 3], {abc=123}, true, false]", a.toString());
+		assertEquals("[1, -1.0e6, abc, [1, 2, 3], {abc=123}, true, false]", a.toString());
 		assertEquals(1.0, a.getDouble(0), 0.001f);
 		assertEquals(1, a.getInt(0));
 		assertEquals(-1000000, a.getInt(1));
@@ -155,94 +151,95 @@ public void testArrayWithEverything() throws JsonParserException {
 	}
 
 	@Test
-	public void testObjectWithEverything() throws JsonParserException {
+	void objectWithEverything() throws JsonParserException {
 		// TODO: Is this deterministic if we use string keys?
 		JsonObject o = JsonParser.object().from(
 				"{\"abc\":123, \"def\":456.0, \"ghi\":[true, false], \"jkl\":null, \"mno\":true}");
 
-		assertEquals(null, o.get("jkl"));
+		assertNull(o.get("jkl"));
 		assertTrue(o.containsKey("jkl"));
-		assertEquals(123, o.get("abc"));
+		assertEquals(123, ((Number) o.get("abc")).intValue());
 		assertEquals(Arrays.asList(true, false), o.get("ghi"));
-		assertEquals(456.0, o.get("def"));
+		assertEquals(456.0, ((Number) o.get("def")).doubleValue());
 		assertEquals(true, o.get("mno"));
 		assertEquals(5, o.size());
 
 		assertEquals(123, o.getInt("abc"));
 		assertEquals(456, o.getInt("def"));
-		assertEquals(true, o.getArray("ghi").getBoolean(0));
-		assertEquals(null, o.get("jkl"));
+		assertTrue(o.getArray("ghi").getBoolean(0));
+		assertNull(o.get("jkl"));
 		assertTrue(o.isNull("jkl"));
 		assertTrue(o.getBoolean("mno"));
 	}
 
 	@Test
-	public void testStringEscapes() throws JsonParserException {
-		assertEquals("\n", JsonParser.any().from("\"\\n\""));
-		assertEquals("\r", JsonParser.any().from("\"\\r\""));
-		assertEquals("\t", JsonParser.any().from("\"\\t\""));
-		assertEquals("\b", JsonParser.any().from("\"\\b\""));
-		assertEquals("\f", JsonParser.any().from("\"\\f\""));
-		assertEquals("/", JsonParser.any().from("\"/\""));
-		assertEquals("\\", JsonParser.any().from("\"\\\\\""));
-		assertEquals("\"", JsonParser.any().from("\"\\\"\""));
-		assertEquals("\0", JsonParser.any().from("\"\\u0000\""));
-		assertEquals("\u8000", JsonParser.any().from("\"\\u8000\""));
-		assertEquals("\uffff", JsonParser.any().from("\"\\uffff\""));
-		assertEquals("\uFFFF", JsonParser.any().from("\"\\uFFFF\""));
+	void stringEscapes() throws JsonParserException {
+		assertEquals("\n", JsonParser.any().from("\"\\n\"").toString());
+		assertEquals("\r", JsonParser.any().from("\"\\r\"").toString());
+		assertEquals("\t", JsonParser.any().from("\"\\t\"").toString());
+		assertEquals("\b", JsonParser.any().from("\"\\b\"").toString());
+		assertEquals("\f", JsonParser.any().from("\"\\f\"").toString());
+		assertEquals("/", JsonParser.any().from("\"/\"").toString());
+		assertEquals("\\", JsonParser.any().from("\"\\\\\"").toString());
+		assertEquals("\"", JsonParser.any().from("\"\\\"\"").toString());
+		assertEquals("\0", JsonParser.any().from("\"\\u0000\"").toString());
+		assertEquals("\u8000", JsonParser.any().from("\"\\u8000\"").toString());
+		assertEquals("\uffff", JsonParser.any().from("\"\\uffff\"").toString());
+		assertEquals("\uFFFF", JsonParser.any().from("\"\\uFFFF\"").toString());
 
 		assertEquals("all together: \\/\n\r\t\b\f (fin)",
-				JsonParser.any().from("\"all together: \\\\\\/\\n\\r\\t\\b\\f (fin)\""));
+				JsonParser.any().from("\"all together: \\\\\\/\\n\\r\\t\\b\\f (fin)\"").toString());
 	}
 
 	@Test
-	public void testStringEscapesAroundBufferBoundary() throws JsonParserException {
+	void stringEscapesAroundBufferBoundary() throws JsonParserException {
 		char[] c = new char[JsonTokener.BUFFER_SIZE - 1024];
-		Arrays.fill(c,  ' ');
+		Arrays.fill(c, ' ');
 		String base = new String(c);
 		for (int i = 0; i < 2048; i++) {
 			base += " ";
-			assertEquals("\u0055", JsonParser.any().from(base + "\"\\u0055\""));
+			assertEquals("\u0055", JsonParser.any().from(base + "\"\\u0055\"").toString());
 		}
 	}
 
 	@Test
-	public void testStringsAroundBufferBoundary() throws JsonParserException {
+	void stringsAroundBufferBoundary() throws JsonParserException {
 		char[] c = new char[JsonTokener.BUFFER_SIZE - 16];
-		Arrays.fill(c,  ' ');
+		Arrays.fill(c, ' ');
 		String base = new String(c);
 		for (int i = 0; i < 32; i++) {
 			base += " ";
-			assertEquals(base, JsonParser.any().from('"' + base + '"'));
+			assertEquals(base, JsonParser.any().from('"' + base + '"').toString());
 		}
 	}
 
 	@Test
-	public void testNumbers() throws JsonParserException {
+	void numbers() throws JsonParserException {
 		String[] testCases = new String[] { "0", "1", "-0", "-1", "0.1", "1.1", "-0.1", "0.10", "-0.10", "0e1", "0e0",
 				"-0e-1", "0.0e0", "-0.0e0", "9" };
 		for (String testCase : testCases) {
-			Number n = (Number)JsonParser.any().from(testCase);
+			Number n = (Number) JsonParser.any().from(testCase);
 			assertEquals(Double.parseDouble(testCase), n.doubleValue(), Double.MIN_NORMAL);
-			Number n2 = (Number)JsonParser.any().from(testCase.toUpperCase());
+			Number n2 = (Number) JsonParser.any().from(testCase.toUpperCase());
 			assertEquals(Double.parseDouble(testCase.toUpperCase()), n2.doubleValue(), Double.MIN_NORMAL);
 		}
 	}
 
 	/**
-	 * Test that negative zero ends up as negative zero in both the parser and the writer.
+	 * Test that negative zero ends up as negative zero in both the parser and the
+	 * writer.
 	 */
 	@Test
-	public void testNegativeZero() throws JsonParserException {
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0.0")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0.0e0")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0e0")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0e1")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0e-1")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0e-0")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0e-01")).doubleValue()));
-		assertEquals("-0.0", Double.toString(((Number)JsonParser.any().from("-0e-000000000001")).doubleValue()));
+	void negativeZero() throws JsonParserException {
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0.0")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0.0e0")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0e0")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0e1")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0e-1")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0e-0")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0e-01")).doubleValue()));
+		assertEquals("-0.0", Double.toString(((Number) JsonParser.any().from("-0e-000000000001")).doubleValue()));
 
 		assertEquals("-0.0", JsonWriter.string(-0.0));
 		assertEquals("-0.0", JsonWriter.string(-0.0f));
@@ -252,21 +249,24 @@ public void testNegativeZero() throws JsonParserException {
 	 * Test the basic numbers from -100 to 100 as a sanity check.
 	 */
 	@Test
-	public void testBasicNumbers() throws JsonParserException {
+	void basicNumbers() throws JsonParserException {
 		for (int i = -100; i <= +100; i++) {
-			assertEquals(i, (int)(Integer)JsonParser.any().from("" + i));
+			Number n = (Number) JsonParser.any().from(Integer.toString(i));
+			assertEquals(i, n.intValue());
 		}
 	}
 
 	@Test
-	public void testBigint() throws JsonParserException {
+	void bigint() throws JsonParserException {
 		JsonObject o = JsonParser.object().from("{\"v\":123456789123456789123456789}");
-		BigInteger bigint = (BigInteger)o.get("v");
-		assertEquals("123456789123456789123456789", bigint.toString());
+		Object raw = o.get("v");
+		// May be parsed as JsonLazyNumber or BigInteger depending on laziness settings
+		String s = raw.toString();
+		assertEquals("123456789123456789123456789", s);
 	}
 
 	@Test
-	public void testFailWrongType() {
+	void failWrongType() {
 		try {
 			JsonParser.object().from("1");
 			fail("Should have failed to parse");
@@ -276,7 +276,7 @@ public void testFailWrongType() {
 	}
 
 	@Test
-	public void testFailNull() {
+	void failNull() {
 		try {
 			JsonParser.object().from("null");
 			fail("Should have failed to parse");
@@ -286,7 +286,7 @@ public void testFailNull() {
 	}
 
 	@Test
-	public void testFailNoJson1() {
+	void failNoJson1() {
 		try {
 			JsonParser.object().from("");
 			fail("Should have failed to parse");
@@ -296,7 +296,7 @@ public void testFailNoJson1() {
 	}
 
 	@Test
-	public void testFailNoJson2() {
+	void failNoJson2() {
 		try {
 			JsonParser.object().from(" ");
 			fail("Should have failed to parse");
@@ -306,7 +306,7 @@ public void testFailNoJson2() {
 	}
 
 	@Test
-	public void testFailNoJson3() {
+	void failNoJson3() {
 		try {
 			JsonParser.object().from("  ");
 			fail("Should have failed to parse");
@@ -316,7 +316,7 @@ public void testFailNoJson3() {
 	}
 
 	@Test
-	public void testFailNumberEdgeCases() {
+	void failNumberEdgeCases() {
 		String[] edgeCases = { "-", ".", "e", "01", "-01", "+01", "01.1", "-01.1", "+01.1", ".1", "-.1", "+.1", "+1",
 				"0.", "-0.", "+0.", "0.e", "-0.e", "+0.e", "0e", "-0e", "+0e", "0e-", "-0e-", "+0e-", "0e+", "-0e+",
 				"+0e+", "-e", "+e", "2.", "-2.", "-1.e1", "1.e1", "0.e1" };
@@ -347,10 +347,11 @@ public void testFailNumberEdgeCases() {
 	}
 
 	/**
-	 * See http://seriot.ch/json/parsing.html and https://github.com/mmastrac/nanojson/issues/3.
+	 * See http://seriot.ch/json/parsing.html and
+	 * https://github.com/mmastrac/nanojson/issues/3.
 	 */
 	@Test
-	public void testFailNumberEdgeCasesFromJSONSuite() {
+	void failNumberEdgeCasesFromJSONSuite() {
 		String[] edgeCases = { "[-2.]", "[0.e1]", "[2.e+3]", "[2.e-3]", "[2.e3]", "[1.]" };
 		for (String edgeCase : edgeCases) {
 			try {
@@ -363,10 +364,11 @@ public void testFailNumberEdgeCasesFromJSONSuite() {
 	}
 
 	/**
-	 * See http://seriot.ch/json/parsing.html and https://github.com/mmastrac/nanojson/issues/3.
+	 * See http://seriot.ch/json/parsing.html and
+	 * https://github.com/mmastrac/nanojson/issues/3.
 	 */
 	@Test
-	public void testFailNumberEdgeCasesFromJSONSuiteNoArray() {
+	void failNumberEdgeCasesFromJSONSuiteNoArray() {
 		String[] edgeCases = { "-2.", "0.e1", "2.e+3", "2.e-3", "2.e3", "1." };
 		for (String edgeCase : edgeCases) {
 			try {
@@ -379,7 +381,7 @@ public void testFailNumberEdgeCasesFromJSONSuiteNoArray() {
 	}
 
 	@Test
-	public void testFailBustedNumber1() {
+	void failBustedNumber1() {
 		try {
 			// There's no 'f' in double, but it treats it as a new token
 			JsonParser.object().from("123f");
@@ -390,7 +392,7 @@ public void testFailBustedNumber1() {
 	}
 
 	@Test
-	public void testFailBustedNumber2() {
+	void failBustedNumber2() {
 		try {
 			// Badly formed number
 			JsonParser.object().from("-1-1");
@@ -401,7 +403,7 @@ public void testFailBustedNumber2() {
 	}
 
 	@Test
-	public void testFailBustedString1() {
+	void failBustedString1() {
 		try {
 			// Missing " at end
 			JsonParser.object().from("\"abc");
@@ -412,7 +414,7 @@ public void testFailBustedString1() {
 	}
 
 	@Test
-	public void testFailBustedString2() {
+	void failBustedString2() {
 		try {
 			// \n in middle of string
 			JsonParser.object().from("\"abc\n\"");
@@ -423,7 +425,7 @@ public void testFailBustedString2() {
 	}
 
 	@Test
-	public void testFailBustedString3() {
+	void failBustedString3() {
 		try {
 			// Bad escape "\x" in middle of string
 			JsonParser.object().from("\"abc\\x\"");
@@ -434,7 +436,7 @@ public void testFailBustedString3() {
 	}
 
 	@Test
-	public void testFailBustedString4() {
+	void failBustedString4() {
 		try {
 			// Bad escape "\\u123x" in middle of string
 			JsonParser.object().from("\"\\u123x\"");
@@ -445,7 +447,7 @@ public void testFailBustedString4() {
 	}
 
 	@Test
-	public void testFailBustedString5() {
+	void failBustedString5() {
 		try {
 			// Incomplete unicode escape
 			JsonParser.object().from("\"\\u222\"");
@@ -456,7 +458,7 @@ public void testFailBustedString5() {
 	}
 
 	@Test
-	public void testFailBustedString6() {
+	void failBustedString6() {
 		try {
 			// String that terminates halfway through a unicode escape
 			JsonParser.object().from("\"\\u222");
@@ -467,7 +469,7 @@ public void testFailBustedString6() {
 	}
 
 	@Test
-	public void testFailBustedString7() {
+	void failBustedString7() {
 		try {
 			// String that terminates halfway through a regular escape
 			JsonParser.object().from("\"\\");
@@ -478,7 +480,7 @@ public void testFailBustedString7() {
 	}
 
 	@Test
-	public void testFailArrayTrailingComma1() {
+	void failArrayTrailingComma1() {
 		try {
 			JsonParser.object().from("[,]");
 			fail();
@@ -488,7 +490,7 @@ public void testFailArrayTrailingComma1() {
 	}
 
 	@Test
-	public void testFailArrayTrailingComma2() {
+	void failArrayTrailingComma2() {
 		try {
 			JsonParser.object().from("[1,]");
 			fail();
@@ -498,7 +500,7 @@ public void testFailArrayTrailingComma2() {
 	}
 
 	@Test
-	public void testFailObjectTrailingComma1() {
+	void failObjectTrailingComma1() {
 		try {
 			JsonParser.object().from("{,}");
 			fail();
@@ -508,7 +510,7 @@ public void testFailObjectTrailingComma1() {
 	}
 
 	@Test
-	public void testFailObjectTrailingComma2() {
+	void failObjectTrailingComma2() {
 		try {
 			JsonParser.object().from("{\"abc\":123,}");
 			fail();
@@ -518,7 +520,7 @@ public void testFailObjectTrailingComma2() {
 	}
 
 	@Test
-	public void testFailObjectBadKey1() {
+	void failObjectBadKey1() {
 		try {
 			JsonParser.object().from("{true:1}");
 			fail();
@@ -528,7 +530,7 @@ public void testFailObjectBadKey1() {
 	}
 
 	@Test
-	public void testFailObjectBadKey2() {
+	void failObjectBadKey2() {
 		try {
 			JsonParser.object().from("{2:1}");
 			fail();
@@ -538,7 +540,7 @@ public void testFailObjectBadKey2() {
 	}
 
 	@Test
-	public void testFailObjectBadColon1() {
+	void failObjectBadColon1() {
 		try {
 			JsonParser.object().from("{\"abc\":}");
 			fail();
@@ -548,7 +550,7 @@ public void testFailObjectBadColon1() {
 	}
 
 	@Test
-	public void testFailObjectBadColon2() {
+	void failObjectBadColon2() {
 		try {
 			JsonParser.object().from("{\"abc\":1:}");
 			fail();
@@ -558,7 +560,7 @@ public void testFailObjectBadColon2() {
 	}
 
 	@Test
-	public void testFailObjectBadColon3() {
+	void failObjectBadColon3() {
 		try {
 			JsonParser.object().from("{:\"abc\":1}");
 			fail();
@@ -568,7 +570,7 @@ public void testFailObjectBadColon3() {
 	}
 
 	@Test
-	public void testFailBadKeywords1() {
+	void failBadKeywords1() {
 		try {
 			JsonParser.object().from("truef");
 			fail();
@@ -578,7 +580,7 @@ public void testFailBadKeywords1() {
 	}
 
 	@Test
-	public void testFailBadKeywords2() {
+	void failBadKeywords2() {
 		try {
 			JsonParser.object().from("true1");
 			fail();
@@ -588,7 +590,7 @@ public void testFailBadKeywords2() {
 	}
 
 	@Test
-	public void testFailBadKeywords3() {
+	void failBadKeywords3() {
 		try {
 			JsonParser.object().from("tru");
 			fail();
@@ -598,7 +600,7 @@ public void testFailBadKeywords3() {
 	}
 
 	@Test
-	public void testFailBadKeywords4() {
+	void failBadKeywords4() {
 		try {
 			JsonParser.object().from("[truef,true]");
 			fail();
@@ -608,7 +610,7 @@ public void testFailBadKeywords4() {
 	}
 
 	@Test
-	public void testFailBadKeywords5() {
+	void failBadKeywords5() {
 		try {
 			JsonParser.object().from("grue");
 			fail();
@@ -618,7 +620,7 @@ public void testFailBadKeywords5() {
 	}
 
 	@Test
-	public void testFailBadKeywords6() {
+	void failBadKeywords6() {
 		try {
 			JsonParser.object().from("trueeeeeeeeeeeeeeeeeeee");
 			fail();
@@ -628,7 +630,7 @@ public void testFailBadKeywords6() {
 	}
 
 	@Test
-	public void testFailBadKeywords7() {
+	void failBadKeywords7() {
 		try {
 			JsonParser.object().from("g");
 			fail();
@@ -638,7 +640,7 @@ public void testFailBadKeywords7() {
 	}
 
 	@Test
-	public void testFailTrailingCommaMultiline() {
+	void failTrailingCommaMultiline() {
 		String testString = "{\n\"abc\":123,\n\"def\":456,\n}";
 		try {
 			JsonParser.object().from(testString);
@@ -652,7 +654,7 @@ public void testFailTrailingCommaMultiline() {
 	 * Ensures that we're correctly tracking UTF-8 character positions.
 	 */
 	@Test
-	public void testFailTrailingCommaUTF8() {
+	void failTrailingCommaUTF8() {
 		ByteArrayInputStream in1 = new ByteArrayInputStream("{\n\"abc\":123,\"def\":456,}".getBytes(Charset
 				.forName("UTF-8")));
 		ByteArrayInputStream in2 = new ByteArrayInputStream(
@@ -676,58 +678,58 @@ public void testFailTrailingCommaUTF8() {
 	}
 
 	@Test
-	public void testEncodingUTF8() throws JsonParserException {
+	void encodingUTF8() throws JsonParserException {
 		testEncoding(UTF8);
 		testEncodingBOM(UTF8);
 	}
 
 	@Test
-	public void testEncodingUTF16LE() throws JsonParserException {
+	void encodingUTF16LE() throws JsonParserException {
 		Charset charset = Charset.forName("UTF-16LE");
 		testEncoding(charset);
 		testEncodingBOM(charset);
 	}
 
 	@Test
-	public void testEncodingUTF16BE() throws JsonParserException {
+	void encodingUTF16BE() throws JsonParserException {
 		Charset charset = Charset.forName("UTF-16BE");
 		testEncoding(charset);
 		testEncodingBOM(charset);
 	}
 
 	@Test
-	public void testEncodingUTF32LE() throws JsonParserException {
+	void encodingUTF32LE() throws JsonParserException {
 		Charset charset = Charset.forName("UTF-32LE");
 		testEncoding(charset);
 		testEncodingBOM(charset);
 	}
 
 	@Test
-	public void testEncodingUTF32BE() throws JsonParserException {
+	void encodingUTF32BE() throws JsonParserException {
 		Charset charset = Charset.forName("UTF-32BE");
 		testEncoding(charset);
 		testEncodingBOM(charset);
 	}
 
 	@Test
-	public void testValidUTF8Codepoint() throws JsonParserException {
+	void validUTF8Codepoint() throws JsonParserException {
 		assertEquals("\ud83d\ude8a",
-				JsonParser.any().from(new ByteArrayInputStream("\"\ud83d\ude8a\"".getBytes(UTF8))));
+				JsonParser.any().from(new ByteArrayInputStream("\"\ud83d\ude8a\"".getBytes(UTF8))).toString());
 	}
 
 	@Test
-	public void testValidUTF8Codepoint2() throws JsonParserException {
+	void validUTF8Codepoint2() throws JsonParserException {
 		assertEquals("\u2602",
-				JsonParser.any().from(new ByteArrayInputStream("\"\u2602\"".getBytes(UTF8))));
+				JsonParser.any().from(new ByteArrayInputStream("\"\u2602\"".getBytes(UTF8))).toString());
 	}
 
 	@Test
-	public void testIllegalUTF8Bytes() {
+	void illegalUTF8Bytes() {
 		// Test the always-illegal bytes
 		int[] failures = new int[] { 0xc0, 0xc1, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff };
 		for (int i = 0; i < failures.length; i++) {
 			try {
-				JsonParser.object().from(new ByteArrayInputStream(new byte[] { '"', (byte)failures[i], '"' }));
+				JsonParser.object().from(new ByteArrayInputStream(new byte[] { '"', (byte) failures[i], '"' }));
 			} catch (JsonParserException e) {
 				testException(e, 1, 2, "UTF-8");
 			}
@@ -736,7 +738,7 @@ public void testIllegalUTF8Bytes() {
 		// Test the continuation bytes outside of a continuation
 		for (int i = 0x80; i <= 0xBF; i++) {
 			try {
-				JsonParser.object().from(new ByteArrayInputStream(new byte[] { '"', (byte)i, '"' }));
+				JsonParser.object().from(new ByteArrayInputStream(new byte[] { '"', (byte) i, '"' }));
 			} catch (JsonParserException e) {
 				testException(e, 1, 2, "UTF-8");
 			}
@@ -744,10 +746,11 @@ public void testIllegalUTF8Bytes() {
 	}
 
 	/**
-	 * See http://seriot.ch/parsing_json.html and https://github.com/mmastrac/nanojson/issues/3.
+	 * See http://seriot.ch/parsing_json.html and
+	 * https://github.com/mmastrac/nanojson/issues/3.
 	 */
 	@Test
-	public void testIllegalUTF8StringFromJSONSuite() {
+	void illegalUTF8StringFromJSONSuite() {
 		try {
 			JsonParser.object().from(new ByteArrayInputStream(new byte[] {
 					'"', (byte) 0xed, (byte) 0xa0, (byte) 0x80, '"' }));
@@ -774,7 +777,7 @@ private void testEncodingBOM(Charset charset) throws JsonParserException {
 	}
 
 	@Test
-	public void failureTestsFromYui() throws IOException {
+	void failureTestsFromYui() throws IOException {
 		InputStream input = getClass().getClassLoader().getResourceAsStream("yui_fail_cases.txt");
 
 		String[] failCases = readAsUtf8(input).split("\n");
@@ -789,7 +792,7 @@ public void failureTestsFromYui() throws IOException {
 	}
 
 	@Test
-	public void tortureTest() throws JsonParserException, IOException {
+	void tortureTest() throws JsonParserException, IOException {
 		InputStream input = getClass().getClassLoader().getResourceAsStream("sample.json");
 		JsonObject o = JsonParser.object().from(readAsUtf8(input));
 		assertNotNull(o.get("a"));
@@ -803,26 +806,26 @@ public void tortureTest() throws JsonParserException, IOException {
 	}
 
 	@Test
-	public void tortureTestUrl() throws JsonParserException {
+	void tortureTestUrl() throws JsonParserException {
 		JsonObject o = JsonParser.object().from(getClass().getClassLoader().getResource("sample.json"));
 		assertNotNull(o.getObject("a").getArray("b\uecee\u8324\u007a\\\ue768.N"));
 	}
 
 	@Test
-	public void tortureTestStream() throws JsonParserException {
+	void tortureTestStream() throws JsonParserException {
 		JsonObject o = JsonParser.object().from(getClass().getClassLoader().getResourceAsStream("sample.json"));
 		assertNotNull(o.getObject("a").getArray("b\uecee\u8324\u007a\\\ue768.N"));
 	}
 
 	@Test
-	public void testIssue38() throws JsonParserException, IOException {
+	void issue38() throws JsonParserException, IOException {
 		// https://github.com/mmastrac/nanojson/issues/38
 		InputStream input = getClass().getClassLoader().getResourceAsStream("issue-38.json");
 		JsonParser.any().from(readAsUtf8(input));
 	}
 
 	@Test
-	public void testEscapeSequencesAcrossBufferBoundary() throws JsonParserException {
+	void escapeSequencesAcrossBufferBoundary() throws JsonParserException {
 		String s1 = "";
 		String s2 = "";
 
@@ -839,7 +842,7 @@ public void testEscapeSequencesAcrossBufferBoundary() throws JsonParserException
 	}
 
 	@Test
-	public void testFailTruncatedEscapeAcrossBufferBoundary() {
+	void failTruncatedEscapeAcrossBufferBoundary() {
 		String s1 = "\\u123";
 		String s2 = "";
 		for (int i = 0; i < 126; i++) {
@@ -856,18 +859,18 @@ public void testFailTruncatedEscapeAcrossBufferBoundary() {
 				JsonParser.object().from("\"" + s2 + s1);
 				fail();
 			} catch (JsonParserException e) {
-				assertTrue(e.getMessage(), e.getMessage().contains("EOF"));
+				assertTrue(e.getMessage().contains("EOF"), e.getMessage());
 			}
 		}
 	}
 
 	/**
 	 * Tests from json.org: http://www.json.org/JSON_checker/
-	 * 
+	 *
 	 * Skips two tests that don't match reality (ie: Chrome).
 	 */
 	@Test
-	public void jsonOrgTest() throws IOException {
+	void jsonOrgTest() throws IOException {
 		InputStream input = getClass().getClassLoader().getResourceAsStream("json_org_test.zip");
 		ZipInputStream zip = new ZipInputStream(input);
 		ZipEntry ze;
@@ -886,7 +889,7 @@ public void jsonOrgTest() throws IOException {
 
 			boolean positive = ze.getName().startsWith("test/pass");
 			int offset = 0;
-			int size = (int)ze.getSize();
+			int size = (int) ze.getSize();
 			byte[] buffer = new byte[size];
 			while (size > 0) {
 				int r = zip.read(buffer, offset, buffer.length - offset);
@@ -931,14 +934,14 @@ private String readAsUtf8(InputStream input) throws IOException {
 	}
 
 	private void testException(JsonParserException e, int linePos, int charPos) {
-		assertEquals(e.getMessage() + " incorrect location",
-				"line " + linePos + " char " + charPos,
-				"line " + e.getLinePosition() + " char " + e.getCharPosition());
+		assertEquals("line " + linePos + " char " + charPos,
+				"line " + e.getLinePosition() + " char " + e.getCharPosition(),
+				e.getMessage() + " incorrect location");
 	}
 
 	private void testException(JsonParserException e, int linePos, int charPos, String inError) {
 		assertEquals("line " + linePos + " char " + charPos,
 				"line " + e.getLinePosition() + " char " + e.getCharPosition());
-		assertTrue("Error did not contain '" + inError + "': " + e.getMessage(), e.getMessage().contains(inError));
+		assertTrue(e.getMessage().contains(inError), "Error did not contain '" + inError + "': " + e.getMessage());
 	}
 }
diff --git a/src/test/java/com/grack/nanojson/JsonReaderTest.java b/src/test/java/com/grack/nanojson/JsonReaderTest.java
index 5913d50..9812bcb 100644
--- a/src/test/java/com/grack/nanojson/JsonReaderTest.java
+++ b/src/test/java/com/grack/nanojson/JsonReaderTest.java
@@ -15,9 +15,9 @@
  */
 package com.grack.nanojson;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -25,9 +25,9 @@
 import java.io.InputStream;
 import java.util.ArrayList;
 
-import org.junit.Test;
-
 import com.grack.nanojson.Users.Friend;
+
+import org.junit.jupiter.api.Test;
 import com.grack.nanojson.Users.User;
 
 /**
@@ -39,7 +39,7 @@ public class JsonReaderTest {
 	 * Read a simple object.
 	 */
 	@Test
-	public void testObject() throws JsonParserException {
+	void object() throws JsonParserException {
 		JsonReader reader = JsonReader.from("{\"a\":1}");
 		assertEquals(JsonReader.Type.OBJECT, reader.current());
 		reader.object();
@@ -54,7 +54,7 @@ public void testObject() throws JsonParserException {
 	 * Read a simple array.
 	 */
 	@Test
-	public void testArray() throws JsonParserException {
+	void array() throws JsonParserException {
 		JsonReader reader = JsonReader.from("[\"a\",1,null]");
 		assertEquals(JsonReader.Type.ARRAY, reader.current());
 		reader.array();
@@ -72,12 +72,12 @@ public void testArray() throws JsonParserException {
 		
 		assertFalse(reader.next());
 	}
-	
+
 	/**
 	 * Assert all the things.
 	 */
 	@Test
-	public void testNestedDetailed() throws JsonParserException {
+	void nestedDetailed() throws JsonParserException {
 		String json = createNestedJson();
 
 		JsonReader reader = JsonReader.from(json);
@@ -126,13 +126,13 @@ public void testNestedDetailed() throws JsonParserException {
 		
 		assertFalse(reader.next());
 	}
-	
+
 	/**
-	 * Same test as {@link JsonReaderTest#testNestedDetailed()}, less assertions to get a better
+	 * Same test as {@link JsonReaderTest#nestedDetailed()}, less assertions to get a better
 	 * feel for the API.
 	 */
 	@Test
-	public void testNestedLight() throws JsonParserException {
+	void nestedLight() throws JsonParserException {
 		String json = createNestedJson();
 
 		JsonReader reader = JsonReader.from(json);
@@ -176,7 +176,7 @@ public void testNestedLight() throws JsonParserException {
 	 * Test reading an multiple arrays (including an empty one) in a object.
 	 */
 	@Test
-	public void testArraysInObject() throws JsonParserException {
+	void arraysInObject() throws JsonParserException {
 		String json = createArraysInObject();
 		JsonReader reader = JsonReader.from(json);
 
@@ -213,7 +213,7 @@ private String createArraysInObject() {
 	 * Test the {@link Users} class from java-json-benchmark.
 	 */
 	@Test
-	public void testJsonBenchmarkUser() throws JsonParserException {
+	void jsonBenchmarkUser() throws JsonParserException {
 		JsonReader reader = JsonReader.from(getClass().getResourceAsStream("/users.json"));
 		
 		parseUsers(reader);
diff --git a/src/test/java/com/grack/nanojson/JsonTypesTest.java b/src/test/java/com/grack/nanojson/JsonTypesTest.java
index 0e2a627..c2dbcf5 100644
--- a/src/test/java/com/grack/nanojson/JsonTypesTest.java
+++ b/src/test/java/com/grack/nanojson/JsonTypesTest.java
@@ -15,24 +15,26 @@
  */
 package com.grack.nanojson;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.math.BigInteger;
 import java.util.Arrays;
 
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
 
 /**
  * Test for the various JSON types.
  */
-public class JsonTypesTest {
+class JsonTypesTest {
 	// CHECKSTYLE_OFF: MagicNumber
 	// CHECKSTYLE_OFF: JavadocMethod
 	@Test
-	public void testObjectInt() {
+	void objectInt() {
 		JsonObject o = new JsonObject();
 		o.put("key", 1);
 		assertEquals(1, o.getInt("key"));
@@ -42,39 +44,39 @@ public void testObjectInt() {
 		assertEquals(1, o.getNumber("key"));
 		assertEquals(1, o.get("key"));
 
-		assertEquals(null, o.getString("key"));
+		assertNull(o.getString("key"));
 		assertEquals("foo", o.getString("key", "foo"));
 		assertFalse(o.isNull("key"));
 	}
 
 	@Test
-	public void testObjectString() {
+	void objectString() {
 		JsonObject o = new JsonObject();
 		o.put("key", "1");
 		assertEquals(0, o.getInt("key"));
 		assertEquals(0L, o.getLong("key"));
 		assertEquals(0, o.getDouble("key"), 0.0001f);
 		assertEquals(0f, o.getFloat("key"), 0.0001f);
-		assertEquals(null, o.getNumber("key"));
+		assertNull(o.getNumber("key"));
 		assertEquals("1", o.get("key"));
 		assertFalse(o.isNull("key"));
 	}
 
 	@Test
-	public void testObjectNull() {
+	void objectNull() {
 		JsonObject o = new JsonObject();
 		o.put("key", null);
 		assertEquals(0, o.getInt("key"));
 		assertEquals(0L, o.getLong("key"));
 		assertEquals(0, o.getDouble("key"), 0.0001f);
 		assertEquals(0f, o.getFloat("key"), 0.0001f);
-		assertEquals(null, o.getNumber("key"));
-		assertEquals(null, o.get("key"));
+		assertNull(o.getNumber("key"));
+		assertNull(o.get("key"));
 		assertTrue(o.isNull("key"));
 	}
 
 	@Test
-	public void testArrayInt() {
+	void arrayInt() {
 		JsonArray o = new JsonArray(Arrays.asList((String) null, null, null,
 				null));
 		o.set(3, 1);
@@ -85,13 +87,13 @@ public void testArrayInt() {
 		assertEquals(1, o.getNumber(3));
 		assertEquals(1, o.get(3));
 
-		assertEquals(null, o.getString(3));
+		assertNull(o.getString(3));
 		assertEquals("foo", o.getString(3, "foo"));
 		assertFalse(o.isNull(3));
 	}
 
 	@Test
-	public void testArrayString() {
+	void arrayString() {
 		JsonArray o = new JsonArray(Arrays.asList((String) null, null, null,
 				null));
 		o.set(3, "1");
@@ -99,40 +101,40 @@ public void testArrayString() {
 		assertEquals(0L, o.getLong(3));
 		assertEquals(0, o.getDouble(3), 0.0001f);
 		assertEquals(0, o.getFloat(3), 0.0001f);
-		assertEquals(null, o.getNumber(3));
+		assertNull(o.getNumber(3));
 		assertEquals("1", o.get(3));
 		assertFalse(o.isNull(3));
 	}
 
 	@Test
-	public void testArrayNull() {
+	void arrayNull() {
 		JsonArray o = new JsonArray(Arrays.asList((String) null, null, null,
 				null));
 		o.set(3, null);
 		assertEquals(0, o.getInt(3));
 		assertEquals(0, o.getDouble(3), 0.0001f);
 		assertEquals(0, o.getFloat(3), 0.0001f);
-		assertEquals(null, o.getNumber(3));
-		assertEquals(null, o.get(3));
+		assertNull(o.getNumber(3));
+		assertNull(o.get(3));
 		assertTrue(o.isNull(3));
 		assertTrue(o.has(3));
 	}
 
 	@Test
-	public void testArrayBounds() {
+	void arrayBounds() {
 		JsonArray o = new JsonArray(Arrays.asList((String) null, null, null,
 				null));
 		assertEquals(0, o.getInt(4));
 		assertEquals(0, o.getDouble(4), 0.0001f);
 		assertEquals(0, o.getFloat(4), 0.0001f);
-		assertEquals(null, o.getNumber(4));
-		assertEquals(null, o.get(4));
+		assertNull(o.getNumber(4));
+		assertNull(o.get(4));
 		assertFalse(o.isNull(4));
 		assertFalse(o.has(4));
 	}
 
 	@Test
-	public void testJsonArrayBuilder() {
+	void jsonArrayBuilder() {
 		// @formatter:off
 		JsonArray a = JsonArray.builder().value(true).value(1.0).value(1.0f)
 				.value(1).value(new BigInteger("1234567890")).value("hi")
@@ -147,7 +149,7 @@ public void testJsonArrayBuilder() {
 	}
 
 	@Test
-	public void testJsonObjectBuilder() {
+	void jsonObjectBuilder() {
 		// @formatter:off
 		JsonObject a = JsonObject
 				.builder()
@@ -185,23 +187,26 @@ public void testJsonObjectBuilder() {
 		}
 	}
 
-	@Test(expected = JsonWriterException.class)
-	public void testJsonArrayBuilderFailCantCloseRoot() {
-		JsonArray.builder().end();
+	@Test
+	void jsonArrayBuilderFailCantCloseRoot() {
+		assertThrows(JsonWriterException.class, () ->
+			JsonArray.builder().end());
 	}
 
-	@Test(expected = JsonWriterException.class)
-	public void testJsonArrayBuilderFailCantAddKeyToArray() {
-		JsonArray.builder().value("abc", 1);
+	@Test
+	void jsonArrayBuilderFailCantAddKeyToArray() {
+		assertThrows(JsonWriterException.class, () ->
+			JsonArray.builder().value("abc", 1));
 	}
 
-	@Test(expected = JsonWriterException.class)
-	public void testJsonArrayBuilderFailCantAddNonKeyToObject() {
-		JsonObject.builder().value(1);
+	@Test
+	void jsonArrayBuilderFailCantAddNonKeyToObject() {
+		assertThrows(JsonWriterException.class, () ->
+			JsonObject.builder().value(1));
 	}
 
 	@Test
-	public void testJsonKeyOrder() {
+	void jsonKeyOrder() {
 		JsonObject a = JsonObject
 			.builder()
 			.value("key01", 1)
diff --git a/src/test/java/com/grack/nanojson/JsonWriterTest.java b/src/test/java/com/grack/nanojson/JsonWriterTest.java
index 062750f..fe98906 100644
--- a/src/test/java/com/grack/nanojson/JsonWriterTest.java
+++ b/src/test/java/com/grack/nanojson/JsonWriterTest.java
@@ -15,9 +15,7 @@
  */
 package com.grack.nanojson;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
+import org.junit.jupiter.api.Test;
 
 import java.io.ByteArrayOutputStream;
 import java.io.OutputStream;
@@ -25,332 +23,396 @@
 import java.io.StringWriter;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 
-import org.junit.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 /**
  * Test for {@link JsonWriter}.
  */
-public class JsonWriterTest {
-	private static final Charset UTF8 = StandardCharsets.UTF_8;
-
-	// CHECKSTYLE_OFF: MagicNumber
-	// CHECKSTYLE_OFF: JavadocMethod
-	// CHECKSTYLE_OFF: EmptyBlock
-	/**
-	 * Test emitting simple values.
-	 */
-	@Test
-	public void testSimpleValues() {
-		assertEquals("true", JsonWriter.string().value(true).done());
-		assertEquals("null", JsonWriter.string().nul().done());
-		assertEquals("1.0", JsonWriter.string().value(1.0).done());
-		assertEquals("1.0", JsonWriter.string().value(1.0f).done());
-		assertEquals("1", JsonWriter.string().value(1).done());
-		assertEquals("\"abc\"", JsonWriter.string().value("abc").done());
-	}
-
-	/**
-	 * Write progressively longer strings to see if we can tickle a boundary
-	 * exception.
-	 */
-	@Test
-	public void testStreamWriterWithNonBMPStringAroundBufferSize() throws JsonParserException {
-		char[] c = new char[JsonWriterBase.BUFFER_SIZE - 128];
-		Arrays.fill(c, ' ');
-		String base = new String(c);
-		for (int i = 0; i < 256; i++) {
-			base += " ";
-			ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-			String s = base + new String(new int[] { 0x10ffff }, 0, 1);
-			JsonWriter.on(bytes).value(s).done();
-			assertEquals(s, JsonParser.any().from(new String(bytes.toByteArray(), UTF8)));
-		}
-	}
-
-	/**
-	 * Write progressively longer strings to see if we can tickle a boundary
-	 * exception.
-	 */
-	@Test
-	public void testStreamWriterWithBMPStringAroundBufferSize() throws JsonParserException {
-		char[] c = new char[JsonWriterBase.BUFFER_SIZE - 128];
-		Arrays.fill(c, ' ');
-		String base = new String(c);
-		for (int i = 0; i < 256; i++) {
-			base += " ";
-			ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-			String s = base + new String(new int[] { 0xffff }, 0, 1);
-			JsonWriter.on(bytes).value(s).done();
-			assertEquals(s, JsonParser.any().from(new String(bytes.toByteArray(), UTF8)));
-		}
-	}
-
-	/**
-	 * Write progressively longer string + array to see if we can tickle a
-	 * boundary exception.
-	 */
-	@Test
-	public void testStreamWriterWithArrayAroundBufferSize() throws JsonParserException {
-		char[] c = new char[JsonWriterBase.BUFFER_SIZE - 128];
-		Arrays.fill(c,  ' ');
-		String base = new String(c);
-		for (int i = 0; i < 256; i++) {
-			base += " ";
-			ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-			String s = base + new String(new int[] { 0x10ffff }, 0, 1);
-			JsonWriter.on(bytes).array().value(s).nul().end().done();
-			String s2 = new String(bytes.toByteArray(), UTF8);
-			JsonArray array = JsonParser.array().from(s2);
-			assertEquals(s, array.get(0));
-			assertEquals(null, array.get(1));
-		}
-	}
-
-	/**
-	 * Test various ways of writing null, as well as various situations.
-	 */
-	@Test
-	public void testNull() {
-		assertEquals("null", JsonWriter.string().value((String) null).done());
-		assertEquals("null", JsonWriter.string().value((Number) null).done());
-		assertEquals("null", JsonWriter.string().nul().done());
-		assertEquals("[null]", JsonWriter.string().array().value((String) null)
-				.end().done());
-		assertEquals("[null]", JsonWriter.string().array().value((Number) null)
-				.end().done());
-		assertEquals("[null]", JsonWriter.string().array().nul().end().done());
-		assertEquals("{\"a\":null}",
-				JsonWriter.string().object().value("a", (String) null).end()
-						.done());
-		assertEquals("{\"a\":null}",
-				JsonWriter.string().object().value("a", (Number) null).end()
-						.done());
-		assertEquals("{\"a\":null}", JsonWriter.string().object().nul("a")
-				.end().done());
-	}
-
-	/**
-	 * Test escaping of chars < 256.
-	 */
-	@Test
-	public void testStringControlCharacters() {
-		StringBuilder chars = new StringBuilder();
-		for (int i = 0; i < 0xa0; i++)
-			chars.append((char) i);
-		chars.append("\u20ff");
-
-		assertEquals(
-				"\"\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000b\\f\\r\\u000e\\u000f\\u0010"
-						+ "\\u0011\\u0012\\u0013\\u0014\\u0015\\u0016\\u0017\\u0018\\u0019\\u001a\\u001b\\u001c\\u001d"
-						+ "\\u001e\\u001f !\\\"#$%&'()*+,-./0123456789:;<=>?@"
-						+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\\u0080\\u0081\\u0082"
-						+ "\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f"
-						+ "\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c"
-						+ "\\u009d\\u009e\\u009f\\u20ff\"",
-				JsonWriter.string(chars.toString()));
-	}
-
-	/**
-	 * Test escaping of chars < 256.
-	 */
-	@Test
-	public void testEscape() {
-		StringBuilder chars = new StringBuilder();
-		for (int i = 0; i < 0xa0; i++)
-			chars.append((char) i);
-
-		assertEquals(
-				"\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000b\\f\\r\\u000e\\u000f\\u0010"
-						+ "\\u0011\\u0012\\u0013\\u0014\\u0015\\u0016\\u0017\\u0018\\u0019\\u001a\\u001b\\u001c\\u001d"
-						+ "\\u001e\\u001f !\\\"#$%&'()*+,-./0123456789:;<=>?@"
-						+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\\u0080\\u0081\\u0082"
-						+ "\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f"
-						+ "\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c"
-						+ "\\u009d\\u009e\\u009f",
-				JsonWriter.escape(chars.toString()));
-	}
-
-	/**
-	 * Torture test for UTF8 character encoding.
-	 */
-	@Test
-	public void testBMPCharacters() throws Exception {
-		StringBuilder builder = new StringBuilder();
-		for (int i = 0; i < 0xD000; i++) {
-			builder.append((char)i);
-		}
-		builder.append("\ue000");
-		builder.append("\uefff");
-		builder.append("\uf000");
-		builder.append("\uffff");
-
-		// Base string
-		String s = JsonWriter.string(builder.toString());
-		assertEquals(builder.toString(), (String)JsonParser.any().from(s));
-
-		// Ensure that it also matches the PrintStream output
-		ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-		JsonWriter.on(new PrintStream(bytes, false, "UTF-8")).value(builder.toString()).done();
-		assertEquals(builder.toString(), (String)JsonParser.any().from(new String(bytes.toByteArray(),
-				UTF8)));
-
-		// Ensure that it also matches the stream output
-		bytes = new ByteArrayOutputStream();
-		JsonWriter.on(bytes).value(builder.toString()).done();
-		assertEquals(builder.toString(), (String)JsonParser.any().from(new String(bytes.toByteArray(),
-				UTF8)));
-	}
-
-	/**
-	 * Torture test for UTF8 character encoding outside the basic multilingual plane.
-	 */
-	@Test
-	public void testNonBMP() throws Exception {
-		StringBuilder builder = new StringBuilder();
-		builder.appendCodePoint(0x10000); // Start of non-BMP
-		builder.appendCodePoint(0x1f601); // GRINNING FACE WITH SMILING EYES
-		builder.appendCodePoint(0x10ffff); // Character.MAX_CODE_POINT
-
-		// Base string
-		String s = JsonWriter.string(builder.toString());
-		assertEquals(builder.toString(), (String)JsonParser.any().from(s));
-
-		// Ensure that it also matches the PrintStream output
-		ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-		JsonWriter.on(new PrintStream(bytes, false, "UTF-8")).value(builder.toString()).done();
-		assertEquals(builder.toString(), (String)JsonParser.any().from(new String(bytes.toByteArray(),
-				UTF8)));
-
-		// Ensure that it also matches the stream output
-		bytes = new ByteArrayOutputStream();
-		JsonWriter.on(bytes).value(builder.toString()).done();
-		assertEquals(builder.toString(), (String)JsonParser.any().from(new String(bytes.toByteArray(),
-				UTF8)));
-	}
-
-	/**
-	 * Basic {@link OutputStream} smoke test.
-	 */
-	@Test
-	public void testWriteToUTF8Stream() {
-		ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-		JsonWriter.on(bytes).object().value("a\n", 1)
-				.value("b", 2).end().done();
-		assertEquals("{\"a\\n\":1,\"b\":2}", new String(bytes.toByteArray(),
-				UTF8));
-	}
-
-	/**
-	 * Basic {@link PrintStream} smoke test.
-	 */
-	@Test
-	public void testWriteToSystemOutLikeStream() throws Exception {
-		ByteArrayOutputStream bytes = new ByteArrayOutputStream();
-		JsonWriter.on(new PrintStream(bytes, false, "UTF-8")).object().value("a\n", 1)
-				.value("b", 2).end().done();
-
-		assertEquals("{\"a\\n\":1,\"b\":2}", new String(bytes.toByteArray(),
-				UTF8));
-	}
-
-	/**
-	 * Test escaping of / when following < to handle </script>.
-	 */
-	@Test
-	public void testScriptEndEscaping() {
-		assertEquals("\"<\\/script>\"", JsonWriter.string(""));
-		assertEquals("\"/script\"", JsonWriter.string("/script"));
-	}
-
-	/**
-	 * Test a simple array.
-	 */
-	@Test
-	public void testArray() {
-		String json = JsonWriter.string().array().value(true).value(false)
-				.value(true).end().done();
-		assertEquals("[true,false,true]", json);
-	}
-
-	/**
-	 * Test an empty array.
-	 */
-	@Test
-	public void testArrayEmpty() {
-		String json = JsonWriter.string().array().end().done();
-		assertEquals("[]", json);
-	}
-
-	/**
-	 * Test an array of empty arrays.
-	 */
-	@Test
-	public void testArrayOfEmpty() {
-		String json = JsonWriter.string().array().array().end().array().end()
-				.end().done();
-		assertEquals("[[],[]]", json);
-	}
-
-	/**
-	 * Test a nested array.
-	 */
-	@Test
-	public void testNestedArray() {
-		String json = JsonWriter.string().array().array().array().value(true)
-				.value(false).value(true).end().end().end().done();
-		assertEquals("[[[true,false,true]]]", json);
-	}
-
-	/**
-	 * Test a nested array.
-	 */
-	@Test
-	public void testNestedArray2() {
-		String json = JsonWriter.string().array().value(true).array().array()
-				.value(false).end().end().value(true).end().done();
-		assertEquals("[true,[[false]],true]", json);
-	}
-
-	/**
-	 * Test a simple object.
-	 */
-	@Test
-	public void testObject() {
-		String json = JsonWriter.string().object().value("a", true)
-				.value("b", false).value("c", true).end().done();
-		assertEquals("{\"a\":true,\"b\":false,\"c\":true}", json);
-	}
-
-	/**
-	 * Test a simple object with indent.
-	 */
-	@Test
-	public void testObjectIndent() {
-		String json = JsonWriter.indent("  ").string().object()
-				.value("a", true).value("b", false).value("c", true).end()
-				.done();
-		assertEquals("{\n  \"a\":true,\n  \"b\":false,\n  \"c\":true\n}", json);
-	}
-
-	/**
-	 * Test a nested object.
-	 */
-	@Test
-	public void testNestedObject() {
-		String json = JsonWriter.string().object().object("a")
-				.value("b", false).value("c", true).end().end().done();
-		assertEquals("{\"a\":{\"b\":false,\"c\":true}}", json);
-	}
-
-	/**
-	 * Test a nested object and array.
-	 */
-	@Test
-	public void testNestedObjectArray() {
-		//@formatter:off
+class JsonWriterTest {
+    private static final Charset UTF8 = StandardCharsets.UTF_8;
+
+    // CHECKSTYLE_OFF: MagicNumber
+    // CHECKSTYLE_OFF: JavadocMethod
+    // CHECKSTYLE_OFF: EmptyBlock
+
+    /**
+     * Test emitting simple values.
+     */
+    @Test
+    void simpleValues() {
+        assertEquals("true", JsonWriter.string().value(true).done());
+        assertEquals("null", JsonWriter.string().nul().done());
+        assertEquals("1.0", JsonWriter.string().value(1.0).done());
+        assertEquals("1.0", JsonWriter.string().value(1.0f).done());
+        assertEquals("1", JsonWriter.string().value(1).done());
+        assertEquals("\"abc\"", JsonWriter.string().value("abc").done());
+    }
+
+    /**
+     * Write progressively longer strings to see if we can tickle a boundary
+     * exception.
+     */
+    @Test
+    void streamWriterWithNonBMPStringAroundBufferSize() throws JsonParserException {
+        char[] c = new char[JsonWriterBase.BUFFER_SIZE - 128];
+        Arrays.fill(c, ' ');
+        StringBuilder base = new StringBuilder(new String(c));
+        for (int i = 0; i < 256; i++) {
+            base.append(" ");
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            String s = base + new String(new int[]{0x10ffff}, 0, 1);
+            JsonWriter.on(bytes).value(s).done();
+            assertEquals(s, ((LazyString) JsonParser.any().from(new String(bytes.toByteArray(), UTF8))).toString());
+        }
+    }
+
+    /**
+     * Write progressively longer strings to see if we can tickle a boundary
+     * exception.
+     */
+    @Test
+    void streamWriterWithBMPStringAroundBufferSize() throws JsonParserException {
+        char[] c = new char[JsonWriterBase.BUFFER_SIZE - 128];
+        Arrays.fill(c, ' ');
+        String base = new String(c);
+        for (int i = 0; i < 256; i++) {
+            base += " ";
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            String s = base + new String(new int[]{0xffff}, 0, 1);
+            JsonWriter.on(bytes).value(s).done();
+            Object parsed = JsonParser.any().from(new String(bytes.toByteArray(), UTF8));
+            assertEquals(s, parsed instanceof LazyString ? parsed.toString() : parsed);
+        }
+    }
+
+    /**
+     * Write progressively longer string + array to see if we can tickle a
+     * boundary exception.
+     */
+    @Test
+    void streamWriterWithArrayAroundBufferSize() throws JsonParserException {
+        char[] c = new char[JsonWriterBase.BUFFER_SIZE - 128];
+        Arrays.fill(c, ' ');
+        String base = new String(c);
+        for (int i = 0; i < 256; i++) {
+            base += " ";
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            String s = base + new String(new int[]{0x10ffff}, 0, 1);
+            JsonWriter.on(bytes).array().value(s).nul().end().done();
+            String s2 = new String(bytes.toByteArray(), UTF8);
+            JsonArray array = JsonParser.array().from(s2);
+            Object first = array.get(0);
+            assertEquals(s, first instanceof LazyString ? first.toString() : first);
+            assertNull(array.get(1));
+        }
+    }
+
+    /**
+     * Test various ways of writing null, as well as various situations.
+     */
+    @Test
+    void testNull() {
+        assertEquals("null", JsonWriter.string().value((String) null).done());
+        assertEquals("null", JsonWriter.string().value((Number) null).done());
+        assertEquals("null", JsonWriter.string().nul().done());
+        assertEquals("[null]", JsonWriter.string().array().value((String) null)
+                .end().done());
+        assertEquals("[null]", JsonWriter.string().array().value((Number) null)
+                .end().done());
+        assertEquals("[null]", JsonWriter.string().array().nul().end().done());
+        assertEquals("{\"a\":null}",
+                JsonWriter.string().object().value("a", (String) null).end()
+                        .done());
+        assertEquals("{\"a\":null}",
+                JsonWriter.string().object().value("a", (Number) null).end()
+                        .done());
+        assertEquals("{\"a\":null}", JsonWriter.string().object().nul("a")
+                .end().done());
+    }
+
+    @Test
+    void separateKeyWriting() {
+        assertEquals("{\"a\":null}",
+                JsonWriter.string().object().key("a").value((Number) null).end()
+                        .done());
+        assertEquals("{\"a\":{\"b\":null}}",
+                JsonWriter.string().object().key("a").object().value("b", (Number) null)
+                        .end().end().done());
+    }
+
+    /**
+     * Test escaping of chars < 256.
+     */
+    @Test
+    void stringControlCharacters() {
+        StringBuilder chars = new StringBuilder();
+        for (int i = 0; i < 0xa0; i++)
+            chars.append((char) i);
+        chars.append("\u20ff");
+
+        assertEquals(
+                "\"\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000b\\f\\r\\u000e\\u000f\\u0010"
+                        + "\\u0011\\u0012\\u0013\\u0014\\u0015\\u0016\\u0017\\u0018\\u0019\\u001a\\u001b\\u001c\\u001d"
+                        + "\\u001e\\u001f !\\\"#$%&'()*+,-./0123456789:;<=>?@"
+                        + "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\\u0080\\u0081\\u0082"
+                        + "\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f"
+                        + "\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c"
+                        + "\\u009d\\u009e\\u009f\\u20ff\"",
+                JsonWriter.string(chars.toString()));
+    }
+
+    /**
+     * Test escaping of chars < 256.
+     */
+    @Test
+    void escape() {
+        StringBuilder chars = new StringBuilder();
+        for (int i = 0; i < 0xa0; i++)
+            chars.append((char) i);
+
+        assertEquals(
+                "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000b\\f\\r\\u000e\\u000f\\u0010"
+                        + "\\u0011\\u0012\\u0013\\u0014\\u0015\\u0016\\u0017\\u0018\\u0019\\u001a\\u001b\\u001c\\u001d"
+                        + "\\u001e\\u001f !\\\"#$%&'()*+,-./0123456789:;<=>?@"
+                        + "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\\u0080\\u0081\\u0082"
+                        + "\\u0083\\u0084\\u0085\\u0086\\u0087\\u0088\\u0089\\u008a\\u008b\\u008c\\u008d\\u008e\\u008f"
+                        + "\\u0090\\u0091\\u0092\\u0093\\u0094\\u0095\\u0096\\u0097\\u0098\\u0099\\u009a\\u009b\\u009c"
+                        + "\\u009d\\u009e\\u009f",
+                JsonWriter.escape(chars.toString()));
+    }
+
+    /**
+     * Torture test for UTF8 character encoding.
+     */
+    @Test
+    void bmpCharacters() throws Exception {
+        StringBuilder builder = new StringBuilder();
+        for (int i = 0; i < 0xD000; i++) {
+            builder.append((char) i);
+        }
+        builder.append("\ue000");
+        builder.append("\uefff");
+        builder.append("\uf000");
+        builder.append("\uffff");
+
+        // Base string
+        String s = JsonWriter.string(builder.toString());
+        Object parsed = JsonParser.any().from(s);
+        assertEquals(builder.toString(), parsed instanceof LazyString ? parsed.toString() : parsed);
+
+        // Ensure that it also matches the PrintStream output
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        JsonWriter.on(new PrintStream(bytes, false, "UTF-8")).value(builder.toString()).done();
+        parsed = JsonParser.any().from(new String(bytes.toByteArray(), UTF8));
+        assertEquals(builder.toString(), parsed instanceof LazyString ? parsed.toString() : parsed);
+
+        // Ensure that it also matches the stream output
+        bytes = new ByteArrayOutputStream();
+        JsonWriter.on(bytes).value(builder.toString()).done();
+        parsed = JsonParser.any().from(new String(bytes.toByteArray(), UTF8));
+        assertEquals(builder.toString(), parsed instanceof LazyString ? parsed.toString() : parsed);
+    }
+
+    /**
+     * Torture test for UTF8 character encoding outside the basic multilingual
+     * plane.
+     */
+    @Test
+    void nonBMP() throws Exception {
+        StringBuilder builder = new StringBuilder();
+        builder.appendCodePoint(0x10000); // Start of non-BMP
+        builder.appendCodePoint(0x1f601); // GRINNING FACE WITH SMILING EYES
+        builder.appendCodePoint(0x10ffff); // Character.MAX_CODE_POINT
+
+        // Base string
+        String s = JsonWriter.string(builder.toString());
+        assertEquals(builder.toString(), ((LazyString) JsonParser.any().from(s)).toString());
+
+        // Ensure that it also matches the PrintStream output
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        JsonWriter.on(new PrintStream(bytes, false, "UTF-8")).value(builder.toString()).done();
+        assertEquals(builder.toString(), ((LazyString) JsonParser.any().from(new String(bytes.toByteArray(),
+                UTF8))).toString());
+
+        // Ensure that it also matches the stream output
+        bytes = new ByteArrayOutputStream();
+        JsonWriter.on(bytes).value(builder.toString()).done();
+        assertEquals(builder.toString(), ((LazyString) JsonParser.any().from(new String(bytes.toByteArray(),
+                UTF8))).toString());
+    }
+
+    /**
+     * Basic {@link OutputStream} smoke test.
+     */
+    @Test
+    void writeToUTF8Stream() {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        JsonWriter.on(bytes).object().value("a\n", 1)
+                .value("b", 2).end().done();
+        assertEquals("{\"a\\n\":1,\"b\":2}", new String(bytes.toByteArray(),
+                UTF8));
+    }
+
+    /**
+     * Basic {@link PrintStream} smoke test.
+     */
+    @Test
+    void writeToSystemOutLikeStream() throws Exception {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        JsonWriter.on(new PrintStream(bytes, false, "UTF-8")).object().value("a\n", 1)
+                .value("b", 2).end().done();
+
+        assertEquals("{\"a\\n\":1,\"b\":2}", new String(bytes.toByteArray(),
+                UTF8));
+    }
+
+    /**
+     * Test escaping of / when following < to handle </script>.
+     */
+    @Test
+    void scriptEndEscaping() {
+        assertEquals("\"<\\/script>\"", JsonWriter.string(""));
+        assertEquals("\"/script\"", JsonWriter.string("/script"));
+    }
+
+    /**
+     * Test a simple array.
+     */
+    @Test
+    void array() {
+        String json = JsonWriter.string().array().value(true).value(false)
+                .value(true).end().done();
+        assertEquals("[true,false,true]", json);
+    }
+
+    /**
+     * Test an empty array.
+     */
+    @Test
+    void arrayEmpty() {
+        String json = JsonWriter.string().array().end().done();
+        assertEquals("[]", json);
+    }
+
+    /**
+     * Test the auto-conversion of Writables.
+     */
+    @Test
+    void writable() {
+        assertEquals("null", JsonWriter.string((JsonConvertible) () -> null));
+        assertEquals("[]", JsonWriter.string((JsonConvertible) ArrayList::new));
+        assertEquals("{}", JsonWriter.string((JsonConvertible) HashMap::new));
+        assertEquals("\"\"", JsonWriter.string((JsonConvertible) () -> ""));
+        assertEquals("1", JsonWriter.string((JsonConvertible) () -> Integer.valueOf(1)));
+        assertEquals("1.0", JsonWriter.string((JsonConvertible) () -> Double.valueOf(1.0)));
+        assertEquals("1", JsonWriter.string((JsonConvertible) () -> Long.valueOf(1)));
+        assertEquals("1.0", JsonWriter.string((JsonConvertible) () -> Float.valueOf(1.0f)));
+        assertEquals(
+                "[null,[1,2,3],{\"a\":1,\"b\":2.0,\"c\":\"a\",\"d\":null,\"e\":[]}]",
+                JsonWriter.string((JsonConvertible) () -> (JsonConvertible) () -> {
+                    ArrayList list = new ArrayList<>();
+                    list.add(null);
+                    list.add((JsonConvertible) () -> new int[]{1, 2, 3});
+                    list.add((JsonConvertible) () -> {
+                        HashMap map = new HashMap<>();
+                        map.put("a", 1);
+                        map.put("b", 2.0);
+                        map.put("c", "a");
+                        map.put("d", null);
+                        map.put("e", (JsonConvertible) ArrayList::new);
+                        return map;
+                    });
+                    return list;
+                }));
+        assertEquals(
+                "Unable to handle type: class java.lang.Object",
+                assertThrows(
+                        JsonWriterException.class,
+                        () -> JsonWriter.string((JsonConvertible) Object::new)).getMessage());
+        assertEquals(
+                "Unable to handle type: class java.lang.Object",
+                assertThrows(
+                        JsonWriterException.class,
+                        () -> JsonWriter.string((JsonConvertible) () -> Arrays.asList("d", 1, new Object())))
+                        .getMessage());
+    }
+
+    /**
+     * Test an array of empty arrays.
+     */
+    @Test
+    void arrayOfEmpty() {
+        String json = JsonWriter.string().array().array().end().array().end()
+                .end().done();
+        assertEquals("[[],[]]", json);
+    }
+
+    /**
+     * Test a nested array.
+     */
+    @Test
+    void nestedArray() {
+        String json = JsonWriter.string().array().array().array().value(true)
+                .value(false).value(true).end().end().end().done();
+        assertEquals("[[[true,false,true]]]", json);
+    }
+
+    /**
+     * Test a nested array.
+     */
+    @Test
+    void nestedArray2() {
+        String json = JsonWriter.string().array().value(true).array().array()
+                .value(false).end().end().value(true).end().done();
+        assertEquals("[true,[[false]],true]", json);
+    }
+
+    /**
+     * Test a simple object.
+     */
+    @Test
+    void object() {
+        String json = JsonWriter.string().object().value("a", true)
+                .value("b", false).value("c", true).end().done();
+        assertEquals("{\"a\":true,\"b\":false,\"c\":true}", json);
+    }
+
+    /**
+     * Test a simple object with indent.
+     */
+    @Test
+    void objectIndent() {
+        String json = JsonWriter.indent("  ").string().object()
+                .value("a", true).value("b", false).value("c", true).end()
+                .done();
+        assertEquals("{\n  \"a\":true,\n  \"b\":false,\n  \"c\":true\n}", json);
+    }
+
+    /**
+     * Test a nested object.
+     */
+    @Test
+    void nestedObject() {
+        String json = JsonWriter.string().object().object("a")
+                .value("b", false).value("c", true).end().end().done();
+        assertEquals("{\"a\":{\"b\":false,\"c\":true}}", json);
+    }
+
+    /**
+     * Test a nested object and array.
+     */
+    @Test
+    void nestedObjectArray() {
+        //@formatter:off
 		String json = JsonWriter.string()
 				.object()
 					.object("a")
@@ -369,17 +431,17 @@ public void testNestedObjectArray() {
 				.end()
 			.done();
 		//@formatter:on
-		assertEquals(
-				"{\"a\":{\"b\":[{\"a\":1,\"b\":2},{\"c\":1.0,\"d\":2.0}],\"c\":[\"a\",\"b\",\"c\"]}}",
-				json);
-	}
-
-	/**
-	 * Test a nested object and array.
-	 */
-	@Test
-	public void testNestedObjectArrayIndent() {
-		//@formatter:off
+        assertEquals(
+                "{\"a\":{\"b\":[{\"a\":1,\"b\":2},{\"c\":1.0,\"d\":2.0}],\"c\":[\"a\",\"b\",\"c\"]}}",
+                json);
+    }
+
+    /**
+     * Test a nested object and array.
+     */
+    @Test
+    void nestedObjectArrayIndent() {
+        //@formatter:off
 		String json = JsonWriter.indent("  ").string()
 				.object()
 					.object("a")
@@ -399,199 +461,210 @@ public void testNestedObjectArrayIndent() {
 			.done();
 		//@formatter:on
 
-		assertEquals(
-				"{\n  \"a\":{\n    \"b\":[{\n      \"a\":1,\n      \"b\":2\n    },{\n"
-						+ "      \"c\":1.0,\n      \"d\":2.0\n    }],\n"
-						+ "    \"c\":[\"a\",\"b\",\"c\"]\n  }\n}", json);
-	}
-
-	/**
-	 * Tests the {@link Appendable} code.
-	 */
-	@Test
-	public void testAppendable() {
-		StringWriter writer = new StringWriter();
-		JsonWriter.on(writer).object().value("abc", "def").end().done();
-		assertEquals("{\"abc\":\"def\"}", writer.toString());
-	}
-
-	/**
-	 * Tests the {@link OutputStream} code.
-	 */
-	@Test
-	public void testOutputStream() {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		JsonWriter.on(out).object().value("abc", "def").end().done();
-		assertEquals("{\"abc\":\"def\"}",
-				new String(out.toByteArray(), UTF8));
-	}
-
-	@Test
-	public void testQuickJson() {
-		assertEquals("true", JsonWriter.string(true));
-	}
-
-	@Test
-	public void testQuickJsonArray() {
-		assertEquals("[1,2,3]", JsonWriter.string(JsonArray.from(1, 2, 3)));
-	}
-
-	@Test
-	public void testQuickArray() {
-		assertEquals("[1,2,3]", JsonWriter.string(Arrays.asList(1, 2, 3)));
-	}
-
-	@Test
-	public void testQuickArrayEmpty() {
-		assertEquals("[]", JsonWriter.string(Collections.emptyList()));
-	}
-
-	@Test
-	public void testQuickObjectArray() {
-		assertEquals("[1,2,3]", JsonWriter.string(new Object[] { 1, 2, 3 }));
-	}
-
-	@Test
-	public void testQuickObjectArrayNested() {
-		assertEquals(
-				"[[1,2],[[3]]]",
-				JsonWriter.string(new Object[] { new Object[] { 1, 2 },
-						new Object[] { new Object[] { 3 } } }));
-	}
-
-	@Test
-	public void testQuickObjectArrayEmpty() {
-		assertEquals("[]", JsonWriter.string(new Object[0]));
-	}
-
-	@Test
-	public void testObjectArrayInMap() {
-		JsonObject o = new JsonObject();
-		o.put("array of string", new String[] { "a", "b", "c" });
-		o.put("array of Boolean", new Boolean[] { true, false });
-		o.put("array of int", new int[] { 1, 2, 3 });
-		o.put("array of JsonObject",
-				new JsonObject[] { new JsonObject(), null });
-
-		String[] bits = { "\"array of JsonObject\":[{},null]",
-				"\"array of Boolean\":[true,false]",
-				"\"array of string\":[\"a\",\"b\",\"c\"]",
-				"\"array of int\":[1,2,3]" };
-		String s = JsonWriter.string(o);
-		for (String bit : bits) {
-			assertTrue("Didn't contain " + bit, s.contains(bit));
-		}
-	}
-
-	@Test
-	public void testFailureNoKeyInObject() {
-		try {
-			JsonWriter.string().object().value(true).end().done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureNoKeyInObject2() {
-		try {
-			JsonWriter.string().object().value("a", 1).value(true).end().done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureKeyInArray() {
-		try {
-			JsonWriter.string().array().value("x", true).end().done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureKeyInArray2() {
-		try {
-			JsonWriter.string().array().value(1).value("x", true).end().done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureNotFullyClosed() {
-		try {
-			JsonWriter.string().array().value(1).done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureNotFullyClosed2() {
-		try {
-			JsonWriter.string().array().done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureEmpty() {
-		try {
-			JsonWriter.string().done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureEmpty2() {
-		try {
-			JsonWriter.string().end();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureMoreThanOneRoot() {
-		try {
-			JsonWriter.string().value(1).value(1).done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureMoreThanOneRoot2() {
-		try {
-			JsonWriter.string().array().value(1).end().value(1).done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
-
-	@Test
-	public void testFailureMoreThanOneRoot3() {
-		try {
-			JsonWriter.string().array().value(1).end().array().value(1).end()
-					.done();
-			fail();
-		} catch (JsonWriterException e) {
-			// OK
-		}
-	}
+        assertEquals(
+                "{\n  \"a\":{\n    \"b\":[{\n      \"a\":1,\n      \"b\":2\n    },{\n"
+                        + "      \"c\":1.0,\n      \"d\":2.0\n    }],\n"
+                        + "    \"c\":[\"a\",\"b\",\"c\"]\n  }\n}",
+                json);
+    }
+
+    /**
+     * Tests the {@link Appendable} code.
+     */
+    @Test
+    void appendable() {
+        StringWriter writer = new StringWriter();
+        JsonWriter.on(writer).object().value("abc", "def").end().done();
+        assertEquals("{\"abc\":\"def\"}", writer.toString());
+    }
+
+    /**
+     * Tests the {@link OutputStream} code.
+     */
+    @Test
+    void outputStream() {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        JsonWriter.on(out).object().value("abc", "def").end().done();
+        assertEquals("{\"abc\":\"def\"}",
+                new String(out.toByteArray(), UTF8));
+    }
+
+    @Test
+    void quickJson() {
+        assertEquals("true", JsonWriter.string(true));
+    }
+
+    @Test
+    void quickJsonArray() {
+        assertEquals("[1,2,3]", JsonWriter.string(JsonArray.from(1, 2, 3)));
+    }
+
+    @Test
+    void quickArray() {
+        assertEquals("[1,2,3]", JsonWriter.string(Arrays.asList(1, 2, 3)));
+    }
+
+    @Test
+    void quickArrayEmpty() {
+        assertEquals("[]", JsonWriter.string(Collections.emptyList()));
+    }
+
+    @Test
+    void quickObjectArray() {
+        assertEquals("[1,2,3]", JsonWriter.string(new Object[]{1, 2, 3}));
+    }
+
+    @Test
+    void quickObjectArrayNested() {
+        assertEquals(
+                "[[1,2],[[3]]]",
+                JsonWriter.string(new Object[]{new Object[]{1, 2},
+                        new Object[]{new Object[]{3}}}));
+    }
+
+    @Test
+    void quickObjectArrayEmpty() {
+        assertEquals("[]", JsonWriter.string(new Object[0]));
+    }
+
+    @Test
+    void objectArrayInMap() {
+        JsonObject o = new JsonObject();
+        o.put("array of string", new String[]{"a", "b", "c"});
+        o.put("array of Boolean", new Boolean[]{true, false});
+        o.put("array of int", new int[]{1, 2, 3});
+        o.put("array of JsonObject",
+                new JsonObject[]{new JsonObject(), null});
+
+        String[] bits = {"\"array of JsonObject\":[{},null]",
+                "\"array of Boolean\":[true,false]",
+                "\"array of string\":[\"a\",\"b\",\"c\"]",
+                "\"array of int\":[1,2,3]"};
+        String s = JsonWriter.string(o);
+        for (String bit : bits) {
+            assertTrue(s.contains(bit), "Didn't contain " + bit);
+        }
+    }
+
+    @Test
+    void failureNoKeyInObject() {
+        try {
+            JsonWriter.string().object().value(true).end().done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureNoKeyInObject2() {
+        try {
+            JsonWriter.string().object().value("a", 1).value(true).end().done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureKeyInArray() {
+        try {
+            JsonWriter.string().array().value("x", true).end().done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureKeyInArray2() {
+        try {
+            JsonWriter.string().array().value(1).value("x", true).end().done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureRepeatedKey() {
+        assertThrows(JsonWriterException.class, () -> JsonWriter.string().object().key("a").value("b", 2).end().done());
+    }
+
+    @Test
+    void failureRepeatedKey2() {
+        assertThrows(JsonWriterException.class, () -> JsonWriter.string().object().key("a").key("b").end().done());
+    }
+
+    @Test
+    void failureNotFullyClosed() {
+        try {
+            JsonWriter.string().array().value(1).done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureNotFullyClosed2() {
+        try {
+            JsonWriter.string().array().done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureEmpty() {
+        try {
+            JsonWriter.string().done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureEmpty2() {
+        try {
+            JsonWriter.string().end();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureMoreThanOneRoot() {
+        try {
+            JsonWriter.string().value(1).value(1).done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureMoreThanOneRoot2() {
+        try {
+            JsonWriter.string().array().value(1).end().value(1).done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
+
+    @Test
+    void failureMoreThanOneRoot3() {
+        try {
+            JsonWriter.string().array().value(1).end().array().value(1).end()
+                    .done();
+            fail();
+        } catch (JsonWriterException e) {
+            // OK
+        }
+    }
 
 }