`TypeAdapterRuntimeTypeWrapper` eagerly resolves the runtime-type adapter, so a failure building an adapter it would have discarded escapes to the caller
Gson version
2.14.0 (also reproduced on 2.12.1, 2.9.0 and 2.8.9)
Java version
Reproduced on OpenJDK 21 and OpenJDK 26. Any JDK with the module system should be affected.
Description
When a value is serialized as an element of an array or a collection, Gson wraps the
component's TypeAdapter in TypeAdapterRuntimeTypeWrapper. On write, that wrapper resolves an
adapter for the value's runtime type and only afterwards decides which of the two adapters to
use:
// com/google/gson/internal/bind/TypeAdapterRuntimeTypeWrapper.java
TypeAdapter<T> chosen = delegate;
Type runtimeType = getRuntimeTypeIfMoreSpecific(type, value);
if (runtimeType != type) {
TypeAdapter<T> runtimeTypeAdapter = (TypeAdapter<T>) context.getAdapter(TypeToken.get(runtimeType));
if (!(runtimeTypeAdapter instanceof ReflectiveTypeAdapterFactory.Adapter)) {
chosen = runtimeTypeAdapter;
} else if (!isReflective(delegate)) {
chosen = delegate; // <-- runtimeTypeAdapter is discarded here
} else {
chosen = runtimeTypeAdapter;
}
}
chosen.write(out, value);The context.getAdapter(...) call is unconditional. When no adapter is registered for the runtime
type, Gson falls back to ReflectiveTypeAdapterFactory, whose construction throws JsonIOException
if a field of that class cannot be made accessible — for instance any class in a package that
java.base does not open.
In the else if (!isReflective(delegate)) branch the result of that call is thrown away: the
user-registered adapter for the declared type is used instead. The exception is therefore raised
while computing a value the method has already decided not to use.
The practical consequence is that registering an adapter for an interface is enough to serialize a
value directly, but not to serialize the same value inside an array or a collection, as soon as the
implementation class is one Gson cannot reflect over. This makes java.lang.reflect.Type graphs
(whose implementations live in sun.reflect.generics.reflectiveObjects) impossible to serialize
without registering an adapter for every internal JDK class, even though a perfectly good adapter for
the declared interface is registered.
Reproduction
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.WildcardType;
import java.util.Arrays;
import java.util.List;
public class GsonRuntimeTypeLookupBug {
static class Holder {
List<? extends Number> field;
}
/** A trivial adapter for WildcardType. It never uses reflection. */
static final TypeAdapter<WildcardType> WILDCARD_ADAPTER = new TypeAdapter<WildcardType>() {
@Override
public void write(JsonWriter out, WildcardType value) throws IOException {
out.value(value == null ? null : value.getTypeName());
}
@Override
public WildcardType read(JsonReader in) throws IOException {
in.nextString();
return null;
}
};
public static void main(String[] args) throws Exception {
ParameterizedType listOfWildcard =
(ParameterizedType) Holder.class.getDeclaredField("field").getGenericType();
WildcardType wildcard = (WildcardType) listOfWildcard.getActualTypeArguments()[0];
// wildcard.getClass() is sun.reflect.generics.reflectiveObjects.WildcardTypeImpl
Gson gson = new GsonBuilder()
.registerTypeAdapter(WildcardType.class, WILDCARD_ADAPTER)
.create();
// works
System.out.println(gson.toJson(wildcard, WildcardType.class));
// throws
System.out.println(gson.toJson(new WildcardType[]{wildcard}, WildcardType[].class));
// throws as well
System.out.println(gson.toJson(Arrays.asList(wildcard),
new TypeToken<List<WildcardType>>() {}.getType()));
}
}Expected behaviour
All three calls serialize the wildcard with WILDCARD_ADAPTER, since it is registered for the
declared type and is not reflective — which is exactly what TypeAdapterRuntimeTypeWrapper decides
in its else if (!isReflective(delegate)) branch.
Actual behaviour
The first call succeeds. The other two fail:
"? extends java.lang.Number"
Exception in thread "main" com.google.gson.JsonIOException: Failed making field
'sun.reflect.generics.reflectiveObjects.WildcardTypeImpl#upperBounds' accessible;
either increase its visibility or write a custom TypeAdapter for its declaring type.
See https://github.com/google/gson/blob/main/Troubleshooting.md#reflection-inaccessible
at com.google.gson.internal.reflect.ReflectionHelper.makeAccessible(ReflectionHelper.java:52)
at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.getBoundFields(...)
at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.create(...)
at com.google.gson.Gson.getAdapter(Gson.java:557)
at com.google.gson.internal.bind.TypeAdapterRuntimeTypeWrapper.write(...)The error message is also misleading here: it advises writing a custom TypeAdapter for the
declaring type, but one is registered for the declared interface, and the internal JDK class the
message names is not something a user can reasonably register an adapter for.
On Gson 2.8.9 the same reproduction fails with the underlying
java.lang.reflect.InaccessibleObjectException instead, so the behaviour predates the
JsonIOException wrapping added in 2.9.0.
Suggested fix
Resolve the runtime-type adapter only when its outcome can actually be used, or tolerate its failure when the declared-type delegate is going to be preferred anyway. For example:
TypeAdapter<T> chosen = delegate;
Type runtimeType = getRuntimeTypeIfMoreSpecific(type, value);
if (runtimeType != type) {
TypeAdapter<T> runtimeTypeAdapter = null;
try {
runtimeTypeAdapter = (TypeAdapter<T>) context.getAdapter(TypeToken.get(runtimeType));
} catch (JsonIOException e) {
// No usable adapter for the runtime type. Only rethrow if the delegate cannot serve either,
// i.e. if we would have chosen the runtime type adapter.
if (isReflective(delegate)) {
throw e;
}
}
if (runtimeTypeAdapter != null) {
...unchanged selection...
}
}
chosen.write(out, value);This keeps the documented order of preference intact and only changes what happens when the third preference — the reflective adapter for the runtime type — cannot be built at all.
Workaround
Registering the adapter with registerTypeHierarchyAdapter instead of registerTypeAdapter avoids
the problem, because the lookup for the runtime type then returns the user adapter rather than a
reflective one:
Gson gson = new GsonBuilder()
.registerTypeHierarchyAdapter(WildcardType.class, WILDCARD_ADAPTER)
.create();Both the array and the collection then serialize as ["? extends java.lang.Number"].
The equivalent workaround for a TypeAdapterFactory is to match assignable types rather than the
exact TypeToken, so that the factory also answers for the runtime implementation class.
Source: google/gson