docs: private static void populateObjectFromJsonObject(final Object objectInstance, final Type objectResolvedType,
              final Object jsonVal, final ClassFieldCache classFieldCache,
              final Map<CharSequence, Object> idToObjectInstance, final List<Runnable> collectionElementAdders) {
  
          // Leave objectInstance empty (or leave fields null) if jsonVal is null
          if (jsonVal == null) {
              return;
          }
  
          // Check jsonVal is JSONObject or JSONArray
          final boolean isJsonObject = jsonVal instanceof JSONObject;
          final boolean isJsonArray = jsonVal instanceof JSONArray;
          if (!(isJsonArray || isJsonObject)) {
              throw new IllegalArgumentException(
                      "Expected JSONObject or JSONArray, got " + jsonVal.getClass().getSimpleName());
          }
          final JSONObject jsonObject = isJsonObject ? (JSONObject) jsonVal : null;
          final JSONArray jsonArray = isJsonArray ? (JSONArray) jsonVal : null;
  
          // Check concrete type of object instance
          final Class<?> rawType = objectInstance.getClass();
          final boolean isMap = Map.class.isAssignableFrom(rawType);
          @SuppressWarnings("unchecked")
          final Map<Object, Object> mapInstance = isMap ? (Map<Object, Object>) objectInstance : null;
          final boolean isCollection = Collection.class.isAssignableFrom(rawType);
          @SuppressWarnings("unchecked")
          final Collection<Object> collectionInstance = isCollection ? (Collection<Object>) objectInstance : null;
          final boolean isArray = rawType.isArray();
          final boolean isObj = !(isMap || isCollection || isArray);
          if ((isMap || isObj) != isJsonObject || (isCollection || isArray) != isJsonArray) {
              throw new IllegalArgumentException("Wrong JSON type for class " + objectInstance.getClass().getName());
          }
  
          // Handle concrete subclasses of generic classes, e.g. ClassInfoList extends List<ClassInfo>
          Type objectResolvedTypeGeneric = objectResolvedType;
          if (objectResolvedType instanceof Class<?>) {
              final Class<?> objectResolvedCls = (Class<?>) objectResolvedType;
              if (Map.class.isAssignableFrom(objectResolvedCls)) {
                  if (!isMap) {
                      throw new IllegalArgumentException("Got an unexpected map type");
                  }
                  objectResolvedTypeGeneric = objectResolvedCls.getGenericSuperclass();
              } else if (Collection.class.isAssignableFrom(objectResolvedCls)) {
                  if (!isCollection) {
                      throw new IllegalArgumentException("Got an unexpected map type");
                  }
                  objectResolvedTypeGeneric = objectResolvedCls.getGenericSuperclass();
              }
          }
  
          // Get type arguments of resolved type of object, and resolve any type variables
          TypeResolutions typeResolutions;
          // keyType is the first type parameter for maps, otherwise null
          Type mapKeyType;
          // valueType is the component type for arrays, the second type parameter for maps,
          // the first type parameter for collections, or null for standard objects (since
          // fields may be of a range of different types for standard objects)
          Type commonResolvedValueType;
          Class<?> arrayComponentType;
          boolean is1DArray;
          if (objectResolvedTypeGeneric instanceof Class<?>) {
              // Not a Map or Collection subclass
              typeResolutions = null;
              mapKeyType = null;
              final Class<?> objectResolvedCls = (Class<?>) objectResolvedTypeGeneric;
              arrayComponentType = isArray ? objectResolvedCls.getComponentType() : null;
              is1DArray = isArray && !arrayComponentType.isArray();
              commonResolvedValueType = null;
          } else if (objectResolvedTypeGeneric instanceof ParameterizedType) {
              // Get mapping from type variables to resolved types, by comparing the concrete type arguments
              // of the expected type to its type parameters
              final ParameterizedType parameterizedResolvedType = (ParameterizedType) objectResolvedTypeGeneric;
              typeResolutions = new TypeResolutions(parameterizedResolvedType);
              // Correlate type variables with resolved types
              final int numTypeArgs = typeResolutions.resolvedTypeArguments.length;
              if (isMap && numTypeArgs != 2) {
                  throw new IllegalArgumentException(
                          "Wrong number of type arguments for Map: got " + numTypeArgs + "; expected 2");
              } else if (isCollection && numTypeArgs != 1) {
                  throw new IllegalArgumentException(
                          "Wrong number of type arguments for Collection: got " + numTypeArgs + "; expected 1");
              }
              mapKeyType = isMap ? typeResolutions.resolvedTypeArguments[0] : null;
              commonResolvedValueType = isMap ? typeResolutions.resolvedTypeArguments[1]
                      : isCollection ? typeResolutions.resolvedTypeArguments[0] : null;
              is1DArray = false;
              arrayComponentType = null;
          } else {
              throw new IllegalArgumentException("Got illegal type: " + objectResolvedTypeGeneric);
          }
          final Class<?> commonValueRawType = commonResolvedValueType == null ? null
                  : JSONUtils.getRawType(commonResolvedValueType);
  
          // For maps and collections, or 1D arrays, all the elements are of the same type. 
          // Look up the constructor for the value type just once for speed.
          Constructor<?> commonValueConstructorWithSizeHint;
          Constructor<?> commonValueDefaultConstructor;
          if (isMap || isCollection || (is1DArray && !JSONUtils.isBasicValueType(arrayComponentType))) {
              // Get value type constructor for Collection, Map or 1D array
              commonValueConstructorWithSizeHint = classFieldCache.getConstructorWithSizeHintForConcreteTypeOf(
                      is1DArray ? arrayComponentType : commonValueRawType);
              if (commonValueConstructorWithSizeHint != null) {
                  // No need for a default constructor if there is a constructor that takes a size hint
                  commonValueDefaultConstructor = null;
              } else {
                  commonValueDefaultConstructor = classFieldCache.getDefaultConstructorForConcreteTypeOf(
                          is1DArray ? arrayComponentType : commonValueRawType);
              }
          } else {
              // There is no single constructor for the fields of objects, and arrays and basic value types
              // have no constructor
              commonValueConstructorWithSizeHint = null;
              commonValueDefaultConstructor = null;
          }
  
          // For standard objects, look up the list of deserializable fields
          final ClassFields classFields = isObj ? classFieldCache.get(rawType) : null;
  
          // Need to deserialize items in the same order as serialization: create all deserialized objects
          // at the current level in Pass 1, recording any ids that are found, then recurse into child nodes
          // in Pass 2 after objects at the current level have all been instantiated.
          ArrayList<ObjectInstantiation> itemsToRecurseToInPass2 = new ArrayList<>();
  
          // Pass 1: Convert JSON objects in JSONObject items into Java objects
          final int numItems = isJsonObject ? jsonObject.items.size()
                  : isJsonArray ? jsonArray.items.size() : /* can't happen */ 0;
          for (int i = 0; i < numItems; i++) {
              // Iterate through items of JSONObject or JSONArray (key is null for JSONArray)
              final Entry<String, Object> jsonObjectItem = isJsonObject ? jsonObject.items.get(i) : null;
              final String itemJsonKey;
              final Object itemJsonValue;
              if (isJsonObject) {
                  itemJsonKey = jsonObjectItem.getKey();
                  itemJsonValue = jsonObjectItem.getValue();
              } else if (isJsonArray) {
                  itemJsonKey = null;
                  itemJsonValue = jsonArray.items.get(i);
              } else {
                  // Can't happen (keep static analyzers happy)
                  throw ClassGraphException.newClassGraphException("This exception should not be thrown");
              }
              final boolean itemJsonValueIsJsonObject = itemJsonValue instanceof JSONObject;
              final boolean itemJsonValueIsJsonArray = itemJsonValue instanceof JSONArray;
              final JSONObject itemJsonValueJsonObject = itemJsonValueIsJsonObject ? (JSONObject) itemJsonValue
                      : null;
              final JSONArray itemJsonValueJsonArray = itemJsonValueIsJsonArray ? (JSONArray) itemJsonValue : null;
  
              // If this is a standard object, look up the field info in the type cache
              FieldTypeInfo fieldTypeInfo;
              if (isObj) {
                  // Standard objects must interpret the key as a string, since field names are strings.
                  // Look up field name directly, using the itemJsonKey string
                  fieldTypeInfo = classFields.fieldNameToFieldTypeInfo.get(itemJsonKey);
                  if (fieldTypeInfo == null) {
                      throw new IllegalArgumentException("Field " + rawType.getName() + "." + itemJsonKey
                              + " does not exist or is not accessible, non-final, and non-transient");
                  }
              } else {
                  fieldTypeInfo = null;
              }
  
              // Standard objects have a different type for each field; arrays have a nested value type;
              // collections and maps have a single common value type for all elements.
              final Type resolvedItemValueType =
                      // For objects, finish resolving partially resolve field types using the set of type
                      // resolutions found by comparing the resolved type of the concrete containing object
                      // with its generic type. (Fields were partially resolved before by substituting type
                      // arguments of subclasses into type variables of superclasses.)
                      isObj ? fieldTypeInfo.getFullyResolvedFieldType(typeResolutions)
                              // For arrays, the item type is the array component type
                              : isArray ? arrayComponentType
                                      // For collections and maps, the value type is the same for all items
                                      : commonResolvedValueType;
  
              // Construct an object of the type needed to hold the value
              final Object instantiatedItemObject;
              if (itemJsonValue == null) {
                  // If JSON value is null, no need to recurse to deserialize the value
                  instantiatedItemObject = null;
  
              } else if (resolvedItemValueType == Object.class) {
                  // For Object-typed fields, we can only deserialize a JSONObject to Map<Object, Object>
                  // or a JSONArray to List<Object>, since we don't have any other type information
                  if (itemJsonValueIsJsonObject) {
                      instantiatedItemObject = new HashMap<>();
                      if (itemsToRecurseToInPass2 == null) {
                          itemsToRecurseToInPass2 = new ArrayList<>();
                      }
                      itemsToRecurseToInPass2.add(new ObjectInstantiation(instantiatedItemObject,
                              ParameterizedTypeImpl.MAP_OF_UNKNOWN_TYPE, itemJsonValue));
  
                  } else if (itemJsonValueIsJsonArray) {
                      instantiatedItemObject = new ArrayList<>();
                      if (itemsToRecurseToInPass2 == null) {
                          itemsToRecurseToInPass2 = new ArrayList<>();
                      }
                      itemsToRecurseToInPass2.add(new ObjectInstantiation(instantiatedItemObject,
                              ParameterizedTypeImpl.LIST_OF_UNKNOWN_TYPE, itemJsonValue));
  
                  } else {
                      // Deserialize basic JSON value for assigning to Object-typed field or as Object-typed element
                      instantiatedItemObject = jsonBasicValueToObject(itemJsonValue, resolvedItemValueType,
                              /* convertStringToNumber = */ false);
                  }
  
              } else if (JSONUtils.isBasicValueType(resolvedItemValueType)) {
                  // For non-recursive (basic) value types, just convert the values directly.
                  if (itemJsonValueIsJsonObject || itemJsonValueIsJsonArray) {
                      throw new IllegalArgumentException(
                              "Got JSONObject or JSONArray type when expecting a simple value type");
                  }
                  // Deserialize basic JSON value
                  instantiatedItemObject = jsonBasicValueToObject(itemJsonValue, resolvedItemValueType,
                          /* convertStringToNumber = */ false);
  
              } else {
                  // Value type is a recursive type (has fields or items)
  
                  if (CharSequence.class.isAssignableFrom(itemJsonValue.getClass())) {
                      // This must be an id ref -- it is a string in a position that requires a recursive type.  
                      // Look up JSON reference, based on the id in itemJsonValue.
                      final Object linkedObject = idToObjectInstance.get(itemJsonValue);
                      if (linkedObject == null) {
                          // Since we are deserializing objects in the same order as they were 
                          // serialized, this should not happen
                          throw new IllegalArgumentException("Object id not found: " + itemJsonValue);
                      }
                      // Use linked value in place of a new object instantiation, but don't recurse
                      instantiatedItemObject = linkedObject;
  
                  } else {
                      // For other items of recursive type (maps, collections, or general objects),
                      // create an empty object instance for the item
                      if (!itemJsonValueIsJsonObject && !itemJsonValueIsJsonArray) {
                          throw new IllegalArgumentException(
                                  "Got simple value type when expecting a JSON object or JSON array");
                      }
                      try {
                          // Call the appropriate constructor for the item, whether its type is array, Collection,
                          // Map or other class type. For collections and Maps, call the size hint constructor
                          // for speed when adding items.
                          final int numSubItems = itemJsonValueIsJsonObject ? itemJsonValueJsonObject.items.size()
                                  : itemJsonValueIsJsonArray ? itemJsonValueJsonArray.items.size()
                                          : /* can't happen */ 0;
                          if ((resolvedItemValueType instanceof Class<?>
                                  && ((Class<?>) resolvedItemValueType).isArray())) {
                              // Instantiate inner array with same number of items as the inner JSONArray
                              if (!itemJsonValueIsJsonArray) {
                                  throw new IllegalArgumentException(
                                          "Expected JSONArray, got " + itemJsonValue.getClass().getName());
                              }
                              instantiatedItemObject = Array.newInstance(
                                      ((Class<?>) resolvedItemValueType).getComponentType(), numSubItems);
                          } else {
                              // For maps and collections, all the elements are of the same type
                              if (isCollection || isMap || is1DArray) {
                                  // Instantiate a Map or Collection, with a size hint if possible
                                  instantiatedItemObject = commonValueConstructorWithSizeHint != null
                                          // Instantiate collection or map with size hint
                                          ? commonValueConstructorWithSizeHint.newInstance(numSubItems)
                                          // Instantiate other object types
                                          : commonValueDefaultConstructor != null
                                                  ? commonValueDefaultConstructor.newInstance()
                                                  : /* can't happen */ null;
                              } else if (isObj) {
                                  // For object types, each field has its own constructor, and the constructor can
                                  // vary if the field type is completely generic (e.g. "T field").
                                  final Constructor<?> valueConstructorWithSizeHint = fieldTypeInfo
                                          .getConstructorForFieldTypeWithSizeHint(resolvedItemValueType,
                                                  classFieldCache);
                                  if (valueConstructorWithSizeHint != null) {
                                      instantiatedItemObject = valueConstructorWithSizeHint.newInstance(numSubItems);
                                  } else {
                                      instantiatedItemObject = fieldTypeInfo.getDefaultConstructorForFieldType(
                                              resolvedItemValueType, classFieldCache).newInstance();
                                  }
                              } else if (isArray && !is1DArray) {
                                  // Construct next innermost array for an array of 2+ dimensions
                                  instantiatedItemObject = Array.newInstance(rawType.getComponentType(), numSubItems);
  
                              } else {
                                  throw new IllegalArgumentException("Got illegal type");
                              }
                          }
                      } catch (final ReflectiveOperationException | SecurityException e) {
                          throw new IllegalArgumentException("Could not instantiate type " + resolvedItemValueType,
                                  e);
                      }
  
                      // Look up any id field in the object (it will be the first field), and if present,
                      // add it to the idToObjectInstance map, so that it is available before recursing 
                      // into any sibling objects.
                      if (itemJsonValue instanceof JSONObject) {
                          final JSONObject itemJsonObject = (JSONObject) itemJsonValue;
                          if (itemJsonObject.objectId != null) {
                              idToObjectInstance.put(itemJsonObject.objectId, instantiatedItemObject);
                          }
                      }
  
                      // Defer recursing into items
                      if (itemsToRecurseToInPass2 == null) {
                          itemsToRecurseToInPass2 = new ArrayList<>();
                      }
                      itemsToRecurseToInPass2.add(
                              new ObjectInstantiation(instantiatedItemObject, resolvedItemValueType, itemJsonValue));
                  }
              }
  
              // Add instantiated items to parent object
              if (isObj) {
                  fieldTypeInfo.setFieldValue(objectInstance, instantiatedItemObject);
              } else if (isMap) {
                  // For maps, key type should be deserialized from strings, to support e.g. Integer as a key type.
                  // This only works for basic object types though (String, Integer, Enum, etc.)
                  final Object mapKey = jsonBasicValueToObject(itemJsonKey, mapKeyType,
                          /* convertStringToNumber = */ true);
                  mapInstance.put(mapKey, instantiatedItemObject);
              } else if (isArray) {
                  Array.set(objectInstance, i, instantiatedItemObject);
              } else if (isCollection) {
                  // Can't add partially-deserialized item objects to Collections yet, since their
                  // hashCode() and equals() methods may depend upon fields that have not yet been set.
                  collectionElementAdders.add(new Runnable() {
                      @Override
                      public void run() {
                          collectionInstance.add(instantiatedItemObject);
                      }
                  });
              }
          }
  
          // Pass 2: Recurse into child items to populate child fields.
          if (itemsToRecurseToInPass2 != null) {
              for (final ObjectInstantiation i : itemsToRecurseToInPass2) {
                  populateObjectFromJsonObject(i.objectInstance, i.type, i.jsonVal, classFieldCache,
                          idToObjectInstance, collectionElementAdders);
              }
          }
      }