Sharing Mutable Objects Using a Safe Accessor Service
Learn more about using a safe accessor service to share mutable objects.
Join the DZone community and get the full member experience.
Join For FreeOn page 54 in his distinguished book "Java Concurrency In Practice," Brian Goetz describes how objects can be safely shared across threads. He lists four options:
- The object is thread-confined, meaning updates to the object are performed by only one owning thread
- The object is shared read-only, meaning this object only needs one-time publication
- The object is inherently thread-safe, meaning it performs synchronization internally
- The object is guarded by some lock
In this post, I will describe a variant that falls into the fourth category. The objects shared are not thread-confined, not read-only, and aren't synchronized internally. I am going to use a read-write lock to guard the object's state. As the presented technique is highly concurrent, it will not require synchronized blocks and gauruntees that no needless thread contention will slow down application performance. Without using synchronized
, it is still garanteed that any change will be visible across all threads that share the object instances; this is achieved by applying certain rules to the shared objects' mutable state.
Composing a Shared Object
The objects shared should adhere to some rules. These rules avoid thread visibility issues and support thread-safety features of the decsribed pattern. Here is an example:
/**
* Safely visible shared object instance.
*/
public final class SharedObject {
/**
* Exclusive read-write lock.
*/
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
/**
* example mutable state field
*/
private volatile String data;
/**
* other mutable state according to the locking idiom rules declared
*/
/**
* Package private default constructor.
*/
SharedObject() {
}
/**
* Package private copy constructor.
*/
SharedObject(SharedObject template) {
this.data = template.data;
}
boolean readLock() {
return lock.readLock().tryLock();
}
boolean writeLock() {
return lock.writeLock().tryLock();
}
void readUnlock() {
lock.readLock().unlock();
}
void writeUnlock() {
lock.writeLock().unlock();
}
public String getData() {
return data;
}
/**
* Package private setter.
*/
void setData(String data) {
this.data = data;
}
}
The object itself contains a ReentrantReadWriteLock
, which will be used as an explicit lock on the object. This lock will allow multiple reads to happen concurrently, as long there is no open write-lock. We do not want to slow down read performance by unnecassary locking of the shared object instances on read access. If, however, a thread does mutate state, then we need to make sure that concurrent reads by other threads do not retrieve invalid state. When the objects write-lock is acquired successfully, reads aren't permitted. That is the idea of ReadWriteLock
.
The SharedObject
instances will be accessed by threads through another "accessor service," which I will decsribe later. But first, let's go through the rules applied to the SharedObject
class to make it (1) thread-safe from a visibility standpoint and (2) usable by the considered accessor service. These rules are:
- The class should only allow object creation and state mutation by package neighbors; this assumes that the class lives in a "joined household" package with his accessor service;
- You should not let the original object references escape from its accessor service; therefore, the class needs to implement a copy constructor;
- One
ReentrantReadWriteLock
is declared for locking state; - Any methods that mutate state should only be available to package neighbor (i.e. the accessor service);
- All mutable states should be declared volatile;
- If the volatile field happens to be an object reference, one of the following must apply:
- The object is at least effectively immutable;
- The object referenced by the field itself adheres to rules 4 to 6;
Rules 4 to 6 make sure that state mutation of SharedObject
will be visible to all threads executing in your application. This is true regardless of whether the concrete memory visibility guarantees are promised by the applied synchronization technique. An object that adheres to the above rules is always visible in its most recent current state, and I am not saying that this state must be valid. Correctness of the objects' state can only be guaranteed if the mutation actions taken are implemented in a thread-safe manner, as described later using the accessor service.
Shared Object Accessor Service
Now, let's look at the above-mentioned service class that actually performs updates on the shared objects in a thread-safe manner:
package org.projectbarbel.playground.safeaccessor;
import java.util.ConcurrentModificationException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Thread safe access to shared object.
*/
public final class SafeAccessorService {
/**
* {@link ConcurrentHashMap} that safely publishes the objects to all threads,
* without letting any of the objects escape.
*/
private final Map<String, SharedObject> sharedObjects = new ConcurrentHashMap<String, SharedObject>();
public SafeAccessorService() {
}
/**
* Managed thread-safe access to shared instances.
*
* @param objectId the object id to access
* @param compoundAction the atomic operation applied to the shared instance
* @param lock the function that locks the object
* @param unlock the function that unlocks the object
* @return the updated instance
*/
private SharedObject access(String objectId, Function<SharedObject, SharedObject> compoundAction,
Function<SharedObject, Boolean> lock, Consumer<SharedObject> unlock) {
// eventually new instance, safely created and one-time published by
// ConcurrentHashmap
SharedObject sharedObject = sharedObjects.computeIfAbsent(objectId, this::createSharedInstance);
if (lock.apply(sharedObject)) {
try {
// thread-safe mutation block; shared object needs to adhere to locking rules
return compoundAction.apply(sharedObject);
} finally {
unlock.accept(sharedObject);
}
} else {
// clients need to be prepared for this to happen
throw new ConcurrentModificationException(
"the shared object with id=" + objectId + " is locked - try again later");
}
}
/**
* Example update method of an accessor service. Others can be defined.
* @param objectId the object to update
* @param data the data to set on the object
*/
public void updateData(String objectId, String data) {
access(objectId, so -> {
so.setData(data);
return so;
}, so -> so.writeLock(), so -> so.writeUnlock());
}
/**
* Get access returns valid snapshot of shared object. The original instance
* should not escape, as it is required that clients always work on valid shared
* state. Method creates an instance if there is non for the passed object id.
*
* @param objectId id of the {@link SharedObject}
* @return copy of shared object
*/
public SharedObject get(String objectId) {
return access(objectId, so -> new SharedObject(so), so -> so.readLock(), so -> so.readUnlock());
}
/**
* Remove an object hard from the map.
*
* @param objectId object id to remove
*/
public void remove(String objectId) {
sharedObjects.remove(objectId);
}
/**
* Method to create new shared object instances.
*
* @param id the id of the shared object.
* @return the created shared object instance
*/
private SharedObject createSharedInstance(String id) {
return new SharedObject();
}
}
The shared objects are stored in a ConcurrentHashmap
(line 18). This ensures one-time publication safety to happen when the object is created by a thread. Storing shared mutable objects inside a ConcurrentHashmap
does ensure one-time publication safety, but will not ensure that changes to mutable objects stored inside the map will become visible to all threads. This guarantee is achieved by the rules on the shared object that I've decsribed above and, eventually, by the applied synchronization technique on state mutation actions.
The access()
method is at the heart of the SaveAccessorService
class. This method will safely create new instances if required (line 36). Then, it acquires the lock (line 37). The lock will either be a read-lock or a write-lock. This depends on the contents of the lock
and unlock
functions passed to the access()
method. If the lock was acquired successfully, the compoundAction
is performed on the object (line 40). Finally, the lock will be released (line 42). If the method fails to acquire a lock (in line 37), it will exit with a ConcurrentModificationException
(line 46). This aggressive no-waiting policy can be weakened by changing the implementation (1) of the lock
and unlock
functions passed to the access()
method or (2) of the defined locking methods in SharedObject
.
Let's look at updateData()
(line 56), which uses the access()
method described above to perform an update operation. The update method calls the access()
method and defines that the compoundAction
should call the setData()
method on the shared object. It also defines that the compoundAction
is performed under the control of a write-lock (line 60). The update()
function only defines a very simple update of the data variable here. If required, users can define much more complicated actions on the shared object instance. These will be executed "atomically," which means that whatever is passed to the access()
method as a compoundAction
should be executed completely under the control of a write-lock.
The get()
method (line 71) also calls the access()
method, but this time, it only creates a snapshot of the object requested under the control of a read-lock. This ensures that the considered snapshot will always have a valid state because the read-lock would fail if there was a write-lock open on the shared instance. Notice that the get()
method will not let an original object reference escape to the client. This technique is sometimes referred to as instance confinement. It ensures that there will not be any unexpected thread-unsafe handling of the original instance outside the accessor service.
Discussing Pros and Cons
The decsribed pattern has advantages and disadvantages. One advantage is that it narrows the scope of the lock to a minimum. Locks are on a per object bases, and they differ between read and write operations. The demonstrated version of the idiom does not wait at all actually. When the lock fails to be acquired, the execution returns to the client with a ConcurrentModificationException
. The client can handle this situation and continue to process some other tasks and return later. There is no waiting here at all.
Clients can choose to weaken such aggressive locking policy so that the threads wait for a certain time for locks to acquire successfully. ReentrantReadWriteLock
offers the tryLock(long timeout, TimeUnit unit)
methods to achieve this. Locking can be adopted by thelock
and unlock
functions passed to the access()
method as well as by changing the implementation of the lock methods of SharedObject
. You could decide to use other locks or locking strategies here. Hence, the presented implementation can easily be fine-tuned to your individual locking requirements. I have presented the "scalability option" with a very low chance for thread contention.
Another advantage of the pattern is that clients can define atomic actions like required. The updateData()
method is a only simple update operation example. But there are other more complex operations possible. The client just needs to add additional methods to the SaveAccessorService
similar to updateData()
. These methods could perform some other compound actions that, for instance, execute multiple setter-methods on the shared object. This is much like adding update methods to services in Spring-based applications where the underlying resource is the database.
Another advantage of the pattern described is that the object itself does not have to take care of any locking. It just offers simple state with getters and setters and contains its exclusive lock object. Compound actions are defined in the service. And these compound actions are guarded by the objects lock by the service. This way, you could easily add complex compound actions that can even span different types of multiple shared objects. So, a compound action is not limited to the state defined in one shared object. This idea obviously requires another level of sensitivity, as compound atomic actions on multiple objects can introduce new multi-threading issues like dead locks.
A disadvantage of the pattern is that one needs to take sensible care of the shared object composition. The shared object must adhere to certain rules; otherwise, there is a chance that references escape from the accessor service and threads read stale data on the shared object. Another disadvantage I can see is that the mutable shared objects need some storage in a map to hide the references from the clients (as mutable shared objects should not escape out of the control of its SafeAccessorService
). This introduces the chance of memory leakage if the clients do not manage the size of the map sensibly. If there are only objects added, never removed, then this will result in increasing old genaration memory usage. This can only be avoided by removing objects through the remove()
method, or by adding another method that clears the whole map.
Performance Considerations
An interesting subject is whether this solution performs better when using synchronized
blocks in any flavor. This certainly depends on many parameters like the question of whether the read operations greatly outgo the write operations. I have not made any statements in this post because this will clearly depend on the concrete application. However, I can say that read-write locks are a performance optimization designed to allow greater concurrency. Brian Goetz states that: "In practice, read-write locks can improve performance for frequently accessed read-mostly data structures on multiprocessor systems; under other conditions, they perform slightly worse then exclusive locks due to their greater complexity" (Java Concurrency In Practice, 2006, p. 286). I am saying this to point out that making a statement that the presented implementation will "outperform" some other implementation would be inherently unqualified. You will have to find out by doing some profiling on your application if you want to make a qualified statement here. You could alternatively acquire the shared object monitor on each update (and read!) and compare such a locking strategy to the one described in this post.
Last Notice on Visibility
Strictly speaking, the SharedObject
I am using in my accessor service does not necessarily need to declare its variables volatile. This is because explicit locks (which I am using) guarantee the same memory visibility semantics like synchronized
blocks. However, consider the SharedObject
class as a safely publishable declaration suiteable for many situations. For instance, if an original reference escapes from the SafeAccessorService
, the described SharedObject
is not immediately failing because it implements the defined rules. Furthermore, in one of my "ultra-fast" processing applications, I am using AtomicBoolean
as a psuedo-lock in the shared object managed by a safe accessor service instance. That means that I abandon all of the sophisticated synchronization techniques supplied by the JDK, just to reach the highest performance possible and the lowest thread contention. In such situations, the rules applied to SharedObject
are absolutely vital because I am loosing all visibility guarantees that explicit locks like ReentrantReadWriteLock
and synchronized
blocks offer inherently. To summarize, volatile can make the whole construct less fragile, often at a low price.
OK, there is certainly more to discuss. Feel free to comment on this post and let me know your thoughts. The code from this post can be found here. There is also a test case that you may find interesting to check out.
Hope you liked the post!
Opinions expressed by DZone contributors are their own.
Comments