In the previous article, I introduced the method of using Java8 to implement the observer pattern (Part 1). This article continues to introduce the relevant knowledge of the Java8 observer pattern. The specific content is as follows:
Thread-safe implementation
The previous chapter introduces the implementation of the observer pattern in a modern Java environment. Although it is simple but complete, this implementation ignores a key issue: thread safety. Most open Java applications are multi-threaded, and the observer mode is mostly used in multi-threaded or asynchronous systems. For example, if an external service updates its database, the application will also receive a message asynchronously and then notify the internal component to update in observer mode, instead of directly registering and listening to the external service.
Thread safety in observer mode is mainly focused on the body of the mode, because thread conflicts are likely to occur when modifying the registered listener collection. For example, one thread tries to add a new listener, while the other thread tries to add a new animal object, which triggers notifications to all registered listeners. Given the order of sequence, the first thread may or may not have completed registration of the new listener before the registered listener receives notification of the added animal. This is a classic case of thread resource competition, and it is this phenomenon that tells developers that they need a mechanism to ensure thread safety.
The easiest solution to this problem is: all operations that access or modify the registration listener list must follow the Java synchronization mechanism, such as:
public synchronized AnimalAddedListener registerAnimalAddedListener (AnimalAddedListener listener) { /*...*/ } public synchronized void unregisterAnimalAddedListener (AnimalAddedListener listener) { /*...*/ } public synchronized void notifyAnimalAddedListeners (Animal animal) { /*...*/ }In this way, at the same time, only one thread can modify or access the registered listener list, which can successfully avoid resource competition issues, but new problems arise, and such constraints are too strict (for more information about synchronized keywords and Java concurrency models, please refer to the official webpage). Through method synchronization, concurrent access to the listener list can be observed at all times. Registering and revoking the listener is a write operation for the listener list, while notifying the listener to access the listener list is a read-only operation. Since access through notification is a read operation, multiple notification operations can be performed simultaneously.
Therefore, as long as there is no listener registration or revocation, as long as the registration is not registered, as long as any number of concurrent notifications can be executed simultaneously without triggering resource competition for the registered listener list. Of course, resource competition in other situations has existed for a long time. In order to solve this problem, resource locking for ReadWriteLock is designed to manage read and write operations separately. The thread-safe ThreadSafeZoo implementation code of Zoo class is as follows:
public class ThreadSafeZoo { private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); protected final Lock readLock = readWriteLock.readLock(); protected final Lock writeLock = readWriteLock.writeLock();private List<Animal> animals = new ArrayList<>();private List<AnimalAddedListener> listeners = new ArrayList<>();public void addAnimal (Animal animal) {// Add the animal to the list of animalsthis.animals.add(animal);// Notify the list of registered listenersthis.notifyAnimalAddedListeners(animal);}public AnimalAddedListener registerAnimalAddedListener (AnimalAddedListener listener) {// Lock the list of listeners for writing this.writeLock.lock();try {// Add the listener to the list of registered listenersthis.listeners.add(listener);} finally {// Unlock the writer lockthis.writeLock.unlock();}return listener;}public void unregisterAnimalAddedListener (AnimalAddedListener listener) {// Lock the list of listeners for writing this.writeLock.lock();try {// Remove the listener from the list of the registered listenersthis.listeners.remove(listener);} finally {// Unlock the writer lockthis.writeLock.unlock();}}public void notifyAnimalAddedListeners (Animal animal) {// Lock the list of listeners for readingthis.readLock.lock();try {// Notify each of the listeners in the list of registered listenersthis.listeners.forEach(listener -> listener.updateAnimalAdded(animal));} finally {// Unlock the reader lockthis.readLock.unlock();}}}Through such deployment, the implementation of Subject can ensure thread safety and multiple threads can issue notifications at the same time. But despite this, there are still two resource competition issues that cannot be ignored:
Concurrent access to each listener. Multiple threads can notify the listener that new animals are needed, which means that a listener may be called by multiple threads at the same time.
Concurrent access to animal list. Multiple threads may add objects to the animal list at the same time. If the order of notifications has an impact, it may lead to resource competition, which requires a concurrent operation processing mechanism to avoid this problem. If the registered listener list receives notification to add animal2 and then receives notification to add animal1, resource competition will occur. However, if the addition of animal1 and animal2 is performed by different threads, it is also possible to complete the addition of animal1 before animal2. Specifically, thread 1 adds animal1 before notifying the listener and locks the module, thread 2 adds animal2 and notifies the listener, and then thread 1 notifies the listener that animal1 has been added. Although resource competition can be ignored when the order of sequence is not considered, the problem is real.
Concurrent access to listeners
Concurrent access listeners can be implemented by ensuring the listeners' thread safety. Adhering to the spirit of "self-responsibility" of the class, the listener has the "obligation" to ensure its own thread safety. For example, for the listener counted above, increasing or decreasing animal numbers by multiple threads may lead to thread safety problems. To avoid this problem, the calculation of animal numbers must be atomic operations (atomic variables or method synchronization). The specific solution code is as follows:
public class ThreadSafeCountingAnimalAddedListener implements AnimalAddedListener { private static AtomicLong animalsAddedCount = new AtomicLong(0);@Overridepublic void updateAnimalAdded (Animal animal) {// Increment the number of animalsSystem.out.println("Total animals added: " + animalsAddedCount);}}The method synchronization solution code is as follows:
public class CountingAnimalAddedListener implements AnimalAddedListener { private static int animalsAddedCount = 0;@Overridepublic synchronized void updateAnimalAdded (Animal animal) {// Increment the number of animalsSystem.out.println("Total animals added: " + animalsAddedCount);}}It should be emphasized that the listener should ensure its own thread safety. Subject needs to understand the internal logic of the listener, rather than simply ensuring thread safety for accessing and modifying the listener. Otherwise, if multiple subjects share the same listener, each subject class has to rewrite thread-safe code. Obviously, such code is not concise enough, so thread-safe needs to be implemented in the listener class.
Ordered notifications of listeners
When the listener is required to execute in an orderly manner, the read and write lock cannot meet the needs, and a new mechanism needs to be introduced to ensure that the call order of the notify function is consistent with the order in which animal is added to zoo. Some people have tried to implement it using method synchronization, but according to the introduction of method synchronization in Oracle documentation, it can be seen that method synchronization does not provide order management of operation execution. It only ensures that atomic operations are not interrupted, and does not guarantee the thread order of first-come-first execution (FIFO). ReentrantReadWriteLock can implement such an execution order, the code is as follows:
public class OrderedThreadSafeZoo { private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true); protected final Lock readLock = readWriteLock.readLock(); protected final Lock writeLock = readWriteLock.writeLock();private List<Animal> animals = new ArrayList<>();private List<AnimalAddedListener> listeners = new ArrayList<>();public void addAnimal (Animal animal) {// Add the animal to the list of animalsthis.animals.add(animal);// Notify the list of registered listenersthis.notifyAnimalAddedListeners(animal);}public AnimalAddedListener registerAnimalAddedListener (AnimalAddedListener listener) {// Lock the list of listeners for writing this.writeLock.lock();try {// Add the listener to the list of registered listenersthis.listeners.add(listener);} finally {// Unlock the writer lockthis.writeLock.unlock();}return listener;}public void unregisterAnimalAddedListener (AnimalAddedListener listener) {// Lock the list of listeners for writing this.writeLock.lock();try {// Remove the listener from the list of the registered listenersthis.listeners.remove(listener);} finally {// Unlock the writer lockthis.writeLock.unlock();}}public void notifyAnimalAddedListeners (Animal animal) {// Lock the list of listeners for readingthis.readLock.lock();try {// Notify each of the listeners in the list of registered listenersthis.listeners.forEach(listener -> listener.updateAnimalAdded(animal));} finally {// Unlock the reader lockthis.readLock.unlock();}}}In this way, register, unregister and notify functions will obtain read and write lock permissions in the order of first-in-first-out (FIFO). For example, thread 1 registers a listener, thread 2 tries to notify the registered listener after starting the registration operation, thread 3 tries to notify the registered listener when thread 2 is waiting for the read-only lock, adopting fair-ordering method, thread 1 completes the registration operation first, then thread 2 can notify the listener, and finally thread 3 notifies the listener. This ensures that the execution order and the starting order of the action are consistent.
If the method synchronization is adopted, although thread 2 queues up first to occupy resources, thread 3 may still obtain the resource lock before thread 2, and it cannot be guaranteed that thread 2 notifies the listener first than thread 3. The key to the problem is: the fair-ordering method can ensure that threads execute in the order in which resources are applied. The order mechanism of read and write locks is very complicated. You should refer to the official documentation of ReentrantReadWriteLock to ensure that the logic of the lock is sufficient to solve the problem.
Thread safety has been implemented so far, and the advantages and disadvantages of extracting the logic of the topic and encapsulating its mixin class into repeatable code units will be introduced in the following chapters.
Theme logic encapsulates to the Mixin class
It is attractive to encapsulate the above-mentioned observer pattern design implementation into the target mixin class. Generally speaking, observers in observer mode contain a collection of registered listeners; register functions responsible for registering new listeners; unregister functions responsible for revoking registered unregister functions and notify functions responsible for notifying listeners. For the above example of the zoo, all other operations of the zoo class except that the animal list is required for the problem are to implement the logic of the subject.
The case of the Mixin class is shown below. It should be noted that in order to make the code more concise, the code about thread safety is removed here:
public abstract class ObservableSubjectMixin<ListenerType> { private List<ListenerType> listeners = new ArrayList<>();public ListenerType registerListener (ListenerType listener) {// Add the listener to the list of registered listenersthis.listeners.add(listener);return listener;}public void unregisterAnimalAddedListener (ListenerType listener) {// Remove the listener from the list of the registered listenersthis.listeners.remove(listener);}public void notifyListeners (Consumer<? super ListenerType> algorithm) {// Execute some function on each of the listenersthis.listeners.forEach(algorithm);}}Because the interface information of the registered listener type is not provided, a specific listener cannot be notified directly, so it is necessary to ensure the universality of the notification function and allow the client to add some functions, such as accepting parameter matching of generic parameter types to be applicable to each listener. The specific implementation code is as follows:
public class ZooUsingMixin extends ObservableSubjectMixin<AnimalAddedListener> { private List<Animal> animals = new ArrayList<>();public void addAnimal (Animal animal) {// Add the animal to the list of animalsthis.animals.add(animal);// Notify the list of registered listenersthis.notifyListeners((listener) -> listener.updateAnimalAdded(animal));}}The biggest advantage of Mixin class technology is to encapsulate the observer-patterned Subject into a repeatable class, rather than repeating the logic in each subject class. In addition, this method makes the implementation of the zoo class simpler, only storing animal information without considering how to store and notify listeners.
However, using mixin classes is not just an advantage. For example, what if you want to store multiple types of listeners? For example, it is also necessary to store the listener type AnimalRemovedListener. The mixin class is an abstract class. Multiple abstract classes cannot be inherited at the same time in Java, and the mixin class cannot be implemented using an interface instead. This is because the interface does not contain state, and the state in the observer mode needs to be used to save the registered listener list.
One solution is to create a listener type ZooListener that will be notified when animals increase and decrease. The code looks like this:
public interface ZooListener { public void onAnimalAdded (Animal animal);public void onAnimalRemoved (Animal animal);}In this way, you can use this interface to implement monitoring of various changes in zoo state using a listener type:
public class ZooUsingMixin extends ObservableSubjectMixin<ZooListener> { private List<Animal> animals = new ArrayList<>();public void addAnimal (Animal animal) {// Add the animal to the list of animalsthis.animals.add(animal);// Notify the list of registered listenersthis.notifyListeners((listener) -> listener.onAnimalAdded(animal));}public void removeAnimal (Animal animal) {// Remove the animal from the list of animalsthis.animals.remove(animal);// Notify the list of registered listenersthis.notifyListeners((listener) -> listener.onAnimalRemoved(animal));}}Combining multiple listener types into one listener interface does solve the problem mentioned above, but there are still shortcomings, which will be discussed in detail in the following chapters.
Multi-Method Listener and Adapter
In the above method, if the listener interface implements too many functions, the interface will be too verbose. For example, Swing MouseListener contains 5 necessary functions. Although you may only use one of them, you must add these 5 functions as long as you use the mouse click event. More likely to use empty function bodies to implement the remaining functions, which will undoubtedly bring unnecessary confusion to the code.
One solution is to create an adapter (the concept comes from the adapter pattern proposed by GoF). The operation of the listener interface is implemented in the form of abstract functions for inheritance of the specific listener class. In this way, the specific listener class can select the functions it needs and use the default operations for functions not needed by adapter. For example, in the ZooListener class in the above example, create ZooAdapter (the naming rules of Adapter are consistent with the listener, you only need to change the Listener in the class name to Adapter), the code is as follows:
public class ZooAdapter implements ZooListener { @Overridepublic void onAnimalAdded (Animal animal) {}@Overridepublic void onAnimalRemoved (Animal animal) {}}At first glance, this adapter class is insignificant, but the convenience it brings cannot be underestimated. For example, for the following specific classes, just select the functions that are useful to them:
public class NamePrinterZooAdapter extends ZooAdapter { @Overridepublic void onAnimalAdded (Animal animal) {// Print the name of the animal that was addedSystem.out.println("Added animal named " + animal.getName());}}There are two alternatives that can also implement the functions of the adapter class: one is to use the default function; the other is to merge the listener interface and the adapter class into a specific class. The default function is newly proposed by Java 8, allowing developers to provide default (defense) implementation methods in the interface.
This update to the Java library is mainly to facilitate developers to implement program extensions without changing the old version of the code, so this method should be used with caution. After using it many times, some developers will feel that the code written in this way is not professional enough, and some developers think this is the feature of Java 8. No matter what, they need to understand what the original intention of this technology is, and then decide whether to use it based on specific questions. The ZooListener interface code implemented using the default function is as follows:
public interface ZooListener { default public void onAnimalAdded (Animal animal) {}default public void onAnimalRemoved (Animal animal) {}}By using default functions, implementing the specific classes of the interface does not need to implement all functions in the interface, but instead selectively implement the required functions. Although this is a relatively simple solution to the interface expansion problem, developers should pay more attention when using it.
The second solution is to simplify the observer mode, omit the listener interface, and use specific classes to implement the functions of the listener. For example, the ZooListener interface becomes the following:
public class ZooListener { public void onAnimalAdded (Animal animal) {}public void onAnimalRemoved (Animal animal) {}}This solution simplifies the hierarchy of the observer pattern, but it is not applicable to all cases, because if the listener interface is merged into a specific class, the specific listener cannot implement multiple listening interfaces. For example, if the AnimalAddedListener and AnimalRemovedListener interfaces are written in the same concrete class, then a single specific listener cannot implement both interfaces at the same time. In addition, the intention of the listener interface is more obvious than that of the specific class. It is obvious that the former is to provide interfaces for other classes, but the latter is not that obvious.
Without appropriate documentation, the developer will not know that there is already a class that plays the role of an interface and implements all its corresponding functions. In addition, the class name does not contain adapters because the class does not fit into a certain interface, so the class name does not specifically imply this intent. To sum up, a specific problem requires choosing a specific method, and no method is omnipotent.
Before we start the next chapter, it is important to mention that adapters are common in observation mode, especially in older versions of Java code. The Swing API is implemented based on adapters, as many old applications use in the observer pattern in Java 5 and Java 6. The listener in the zoo case may not require an adapter, but it needs to understand the purpose of the adapter and its application, because we can use it in existing code. The following chapter will introduce time-intensive listeners. This type of listener may perform time-consuming operations or make asynchronous calls and cannot immediately give the return value.
Complex & Blocking Listener
One assumption about the observer pattern is that when a function is executed, a series of listeners are called, but it is assumed that this process is completely transparent to the caller. For example, when client code adds animal in Zoo, it is not known that a series of listeners will be called before the return is successful. If the execution of a listener takes a long time (its time is affected by the number of listeners, the execution time of each listener), the client code will be aware of the time side effects of this simple increase in animal operations.
This article cannot discuss this topic in a comprehensive way. The following are the things developers should pay attention to when calling complex listeners:
The listener starts a new thread. After the new thread is started, while executing the listener logic in the new thread, the processing results of the listener function are returned and other listeners are run.
Subject starts a new thread. Unlike traditional linear iterations of registered listener lists, Subject's notify function restarts a new thread and then iterates over the listener list in the new thread. This allows the notify function to output its return value while performing other listeners operations. It should be noted that a thread safety mechanism is needed to ensure that the listener list does not undergo concurrent modifications.
Queue listener calls and performs listening functions with a set of threads. Encapsulate listener operations in some functions and queue them instead of a simple iterative call to the List of Listeners. Once these listeners are stored in the queue, the thread can pop a single element from the queue and execute its listening logic. This is similar to the producer-consumer problem. The notify process produces a queue of executable functions, which then threads take out the queue in turn and execute these functions. The function needs to store the time it was created rather than the time it was executed for the listener function to call. For example, a function created when the listener is called, then the function needs to store the point in time. This function is similar to the following operations in Java:
public class AnimalAddedFunctor { private final AnimalAddedListener listener;private final Animal parameter;public AnimalAddedFunctor (AnimalAddedListener listener, Animal parameter) {this.listener = listener;this.parameter = parameter;}public void execute () {// Execute the listener with the parameter provided during creationthis.listener.updateAnimalAdded(this.parameter);}}Functions are created and saved in a queue and can be called at any time, so that there is no need to perform their corresponding operations immediately when traversing the List of List of List. Once each function that activates the listener is pushed into the queue, the "consumer thread" will return the operational rights to the client code. The "consumer thread" will execute these functions at some point later, just as if the listener is activated by the notify function. This technology is called parameter binding in other languages, which just fits the example above. The essence of the technology is to save the parameters of the listener and then call the execute() function directly. If the listener receives multiple parameters, the processing method is similar.
It should be noted that if you want to save the execution order of the listener, you need to introduce a comprehensive sorting mechanism. In Scheme 1, the listener activates new threads in normal order, which ensures that the listener executes in the order of registration. In Scheme 2, queues support sorting, and the functions in them will be executed in the order they enter the queue. Simply put, developers need to pay attention to the complexity of multi-threaded execution of listeners and handle it carefully to ensure that they implement the required functions.
Conclusion
Before the Observer model was written into the book in 1994, it was already a mainstream software design model, providing many satisfactory solutions to problems that often arise in software design. Java has always been a leader in using this pattern and encapsulates this pattern in its standard library, but given that Java has been updated to version 8, it is very necessary to re-examine the use of classic patterns in it. With the emergence of lambda expressions and other new structures, this "old" pattern has taken new vitality. Whether it is handling old programs or using this long-standing method to solve new problems, especially for experienced Java developers, the observer pattern is the main tool for developers.
OneAPM provides you with end-to-end Java application performance solutions. We support all common Java frameworks and application servers to help you quickly discover system bottlenecks and locate the root causes of abnormalities. Deployment at minute levels and experience instantly, Java monitoring has never been easier. To read more technical articles, please visit OneAPM's official technology blog.
The above content introduces to you how to use Java8 to implement the observer mode (Part 2), I hope it will be helpful to everyone!