MutabilityDetector version 0.10.6 has now been released, and is available from Maven Central.
Biggest change is that Mutability Detector now supports Java bytecode up to v17. Courtesy of @l0s (github.com/l0s). Thank you!
Discussing the design, decisions and discovery involved in developing Mutability Detector, an open-source analysis tool for Java.
MutabilityDetector version 0.10.6 has now been released, and is available from Maven Central.
Biggest change is that Mutability Detector now supports Java bytecode up to v17. Courtesy of @l0s (https://github.com/l0s). Thank you!
<dependency>
<groupid>org.mutabilitydetector</groupid>
<artifactid>MutabilityDetector</artifactid>
<version>0.9</version>
<scope>test</scope>
</dependency>
assertImmutable(Class.forName("javax.time.zone.StandardZoneRules"));
Fails with:org.mutabilitydetector.unittesting.MutabilityAssertionError:
Expected: javax.time.zone.StandardZoneRules to be IMMUTABLE
but: javax.time.zone.StandardZoneRules is actually NOT_IMMUTABLE
Reasons:
Field can have a mutable type (java.util.concurrent.ConcurrentHashMap) assigned to it. [Field: lastRulesCache, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: standardTransitions, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: standardOffsets, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: savingsLocalTransitions, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: wallOffsets, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: savingsInstantTransitions, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: lastRules, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (java.util.concurrent.ConcurrentHashMap) assigned to it. [Field: lastRulesCache, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: standardTransitions, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: standardOffsets, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: savingsInstantTransitions, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: wallOffsets, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: lastRules, Class: javax.time.zone.StandardZoneRules]
Field can have a mutable type (a primitive array) assigned to it. [Field: savingsLocalTransitions, Class: javax.time.zone.StandardZoneRules]
Field is a primitive array. [Field: standardTransitions, Class: javax.time.zone.StandardZoneRules]
Field is a primitive array. [Field: standardOffsets, Class: javax.time.zone.StandardZoneRules]
Field is a primitive array. [Field: savingsInstantTransitions, Class: javax.time.zone.StandardZoneRules]
Field is a primitive array. [Field: savingsLocalTransitions, Class: javax.time.zone.StandardZoneRules]
Field is a primitive array. [Field: wallOffsets, Class: javax.time.zone.StandardZoneRules]
Field is a primitive array. [Field: lastRules, Class: javax.time.zone.StandardZoneRules]
Allowed reasons:
None.
This time, a huge 21 reasons why StandardZoneRules is mutable. But it's not, it's immutable. These 21 reasons fall into roughly the same category though. The class has both a mutable type (ConcurrentHashMap) and array types as fields. This causes Mutability Detector to basically panic without further analysis to see if the mutable things are actually modified. However, all the array fields are final, encapsulated, and contain elements which we consider to be immutable. The ConcurrentHashMap is modified, but it's another example of an unobservable mutation, an internal caching strategy.assertInstancesOf(Class.forName("javax.time.zone.StandardZoneRules"),
areImmutable(),
AssumingTheFields.named("lastRulesCache").areModifiedAsPartAsAnUnobservableCachingStrategy(),
AssumingArrayFields.named("standardTransitions",
"standardOffsets",
"savingsLocalTransitions",
"wallOffsets",
"savingsInstantTransitions",
"lastRules")
.areNotModifiedAndDoNotEscape());
assertImmutable(Class.forName("javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion"));
org.mutabilitydetector.unittesting.MutabilityAssertionError:
Expected: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion to be IMMUTABLE
but: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion is actually NOT_IMMUTABLE
Reasons:
Can be subclassed, therefore parameters declared to be this type could be mutable subclasses at runtime. [Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Field can have a mutable type (javax.time.zone.ResourceZoneRulesDataProvider) assigned to it. [Field: provider, Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Field can have a mutable type (java.lang.String) assigned to it. [Field: versionID, Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Field can have a mutable type (a primitive array) assigned to it. [Field: regionArray, Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Field can have a mutable type (a primitive array) assigned to it. [Field: ruleIndices, Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Field is a primitive array. [Field: regionArray, Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Field is a primitive array. [Field: ruleIndices, Class: javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion]
Allowed reasons:
None.
assertInstancesOf(Class.forName("javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion"),
areImmutable(),
provided(String.class).isAlsoImmutable(),
provided("javax.time.zone.ResourceZoneRulesDataProvider").isAlsoImmutable());
assertInstancesOf(Class.forName("javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion"),
areImmutable(),
provided(String.class).isAlsoImmutable(),
provided("javax.time.zone.ResourceZoneRulesDataProvider").isAlsoImmutable(),
allowingForSubclassing());
This would allow the class to be extended. Which leads us to the remaining failures, that "regionArray" and "rulesIndices" are mutable array fields. The element types of these arrays are String and short, respectively. Thus we need not worry that the elements themselves can be mutated. We need only worry that the array is protected from mutation. In this case they are: regionArray is defensively copied into an unmodifiable Set, to provide a getter; and rulesIndices is never exposed. Thus, as in previous examples, we can get the test to pass by assuming these fields are handled safely, and creating an appropriate allowed reason:public class AssumingArrayFields {
private ImmutableSet fieldNames;
public AssumingArrayFields(ImmutableSet fieldNames) {
this.fieldNames = fieldNames;
}
public static AssumingArrayFields named(String first, String... rest) {
return new AssumingArrayFields(ImmutableSet.copyOf(Iterables.concat(asList(first), asList(rest))));
}
public Matcher areNotModifiedAndDoNotEscape() {
return new TypeSafeDiagnosingMatcher() {
@Override public void describeTo(Description description) { }
@Override
protected boolean matchesSafely(MutableReasonDetail reasonDetail, Description mismatchDescription) {
if (reasonDetail.codeLocation() instanceof FieldLocation) {
return reasonDetail.reason().isOneOf(MUTABLE_TYPE_TO_FIELD, ARRAY_TYPE_INHERENTLY_MUTABLE)
&& fieldNames.contains(((FieldLocation)reasonDetail.codeLocation()).fieldName());
} else {
return false;
}
}
};
}
}
Which allows us to write a passing test:assertInstancesOf(Class.forName("javax.time.zone.ResourceZoneRulesDataProvider$ResourceZoneRulesVersion"),
areImmutable(),
provided(String.class).isAlsoImmutable(),
provided("javax.time.zone.ResourceZoneRulesDataProvider").isAlsoImmutable(),
allowingForSubclassing(),
AssumingArrayFields.named("regionArray", "ruleIndices").areNotModifiedAndDoNotEscape());
org.mutabilitydetector.unittesting.MutabilityAssertionError:
Expected: javax.time.zone.ZoneOffsetTransitionRule to be IMMUTABLE
but: javax.time.zone.ZoneOffsetTransitionRule is actually NOT_IMMUTABLE
Reasons:
Field is not final, if shared across threads the Java Memory Model will not guarantee it is initialised before it is read. [Field: timeEndOfDay, Class: javax.time.zone.ZoneOffsetTransitionRule]
Field can have a mutable type (javax.time.ZoneOffset) assigned to it. [Field: standardOffset, Class: javax.time.zone.ZoneOffsetTransitionRule]
Field can have a mutable type (javax.time.ZoneOffset) assigned to it. [Field: offsetBefore, Class: javax.time.zone.ZoneOffsetTransitionRule]
Field can have a mutable type (javax.time.ZoneOffset) assigned to it. [Field: offsetAfter, Class: javax.time.zone.ZoneOffsetTransitionRule]
Allowed reasons:
None.
assertInstancesOf(javax.time.zone.ZoneOffsetTransitionRule.class,
areImmutable(),
provided(ZoneOffset.class).isAlsoImmutable());
org.mutabilitydetector.unittesting.MutabilityAssertionError:
Expected: javax.time.zone.ZoneRulesGroup to be IMMUTABLE
but: javax.time.zone.ZoneRulesGroup is actually NOT_IMMUTABLE
Reasons:
Field is not final, if shared across threads the Java Memory Model will not guarantee it is initialised before it is read. [Field: versions, Class: javax.time.zone.ZoneRulesGroup]
Field can have a mutable type (java.util.concurrent.atomic.AtomicReference) assigned to it. [Field: versions, Class: javax.time.zone.ZoneRulesGroup]
Field can have a mutable type (java.lang.String) assigned to it. [Field: groupID, Class: javax.time.zone.ZoneRulesGroup]
Allowed reasons:
None.
It's becomming a common and irritating theme, but we can suppress the String Problem as per the previous examples. That leaves us with two issues: that ZoneRulesGroup has a non-final field, and that same field is a mutable type (AtomicReference).private AtomicReference<TreeMap<String, ZoneRulesVersion>> versions =
new AtomicReference<TreeMap<String, ZoneRulesVersion>>(new TreeMap<String, ZoneRulesVersion>(Collections.reverseOrder()));
"A properly constructed object can be safely published by [...] Storing a reference to it into a volatile field or AtomicReference".
assertInstancesOf(javax.time.zone.ZoneRulesGroup.class,
areImmutable(),
provided(String.class).isAlsoImmutable(),
assumingTheFields("versions").areModifiedAsPartAsAnUnobservableCachingStrategy());
If there's some reason that the field cannot be final that I don't understand, Mutability Detector ships with an allowed reason implementation which can help here. Keeping the field non-final, an assertion could be:
assertInstancesOf(javax.time.zone.ZoneRulesGroup.class,
areImmutable(),
provided(String.class).isAlsoImmutable(),
assumingTheFields("versions").areModifiedAsPartAsAnUnobservableCachingStrategy(),
allowingNonFinalFields());
But, "allowingNonFinalFields()" can be overly permissive, allowing new non-final fields to be added in such a way that they don't guarantee safe publication. Perhaps it would be better to specify the field by name, as with the other allowed reason.
org.mutabilitydetector.unittesting.MutabilityAssertionError:
Expected: javax.time.zone.ResourceZoneRulesDataProvider to be IMMUTABLE
but: javax.time.zone.ResourceZoneRulesDataProvider is actually NOT_IMMUTABLE
Reasons:
Field can have a mutable type (java.lang.String) assigned to it. [Field: groupID, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Field can have a mutable type (java.util.HashSet) assigned to it. [Field: regions, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Field can have a mutable type (java.util.HashSet) assigned to it. [Field: versions, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Field can have a mutable type (java.util.concurrent.atomic.AtomicReferenceArray) assigned to it. [Field: rules, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Allowed reasons:
None.
The first failure reason, exemplifies The String Problem. Which I can suppress by changing the assertion to:final class ResourceZoneRulesDataProvider {
private final Set<ZoneRulesVersion> versions;
private final Set<String> regions;
private ResourceZoneRulesDataProvider(URL url) {
// constructor code reading from an input stream
this.regions = new HashSet<String>(Arrays.asList(aLocallyPopulatedArrayVariable));
this.versions = aLocallyPopulatedHashSet;
}
public Set<ZoneRulesVersion> getVersions() {
return versions;
}
public Set<String> getRegionIDs() {
return regions;
}
}
static final class AssumingTheFields {
private Set<String> fieldNames;
private AssumingTheFields(Set<String> fieldNames) {
this.fieldNames = fieldNames;
}
public static AssumingTheFields assumingTheFields(String first, String... rest) {
return new AssumingTheFields(Sets.newHashSet(concat(asList(first), asList(rest))));
}
Matcher<MutableReasonDetail> areNotModifiedByCallers() {
return new TypeSafeDiagnosingMatcher<MutableReasonDetail>() {
@Override public void describeTo(Description description) { }
@Override
protected boolean matchesSafely(MutableReasonDetail item, Description mismatchDescription) {
CodeLocation<?> locationOfMutability = item.codeLocation();
if (locationOfMutability instanceof FieldLocation) {
return item.reason().isOneOf(MUTABLE_TYPE_TO_FIELD)
&& fieldNames.contains(((FieldLocation)locationOfMutability).fieldName());
} else {
return false;
}
}
};
}
}
org.mutabilitydetector.unittesting.MutabilityAssertionError:
Expected: javax.time.zone.ResourceZoneRulesDataProvider to be IMMUTABLE
but: javax.time.zone.ResourceZoneRulesDataProvider is actually NOT_IMMUTABLE
Reasons:
Field can have a mutable type (java.util.concurrent.atomic.AtomicReferenceArray) assigned to it. [Field: rules, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Allowed reasons:
Field can have a mutable type (java.lang.String) assigned to it. [Field: groupID, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Field can have a mutable type (java.util.HashSet) assigned to it. [Field: regions, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Field can have a mutable type (java.util.HashSet) assigned to it. [Field: versions, Class: javax.time.zone.ResourceZoneRulesDataProvider]
Allowing these fields to be mutable leaves us with just one failure. In this case the "rules" field, which is of type AtomicReferenceArray. This is a very similar problem to the previous one. In this case, the collection does not escape, but its contents are modified in one of the instance methods. This appears to be implementation detail of a caching strategy, not an observable change. Again, the scope to invoke this method is limited, and with the current state of the code, it looks to be used safely. As such, we can just treat this field like the escaping Sets, so our assertion can look like this:| Group ID | Artifact ID | Version |
| org.mutabilitydetector | MutabilityDetector | 0.7 |