|
|
|||||||||||||
by Jeff Friesen
| |||||||||||||
| ||||||
Threads may execute in a manner where their paths of execution are completely independent of each other. Neither thread depends upon the other for assistance. For example, one thread might execute a print job, while a second thread repaints a window. And then there are threads that require synchronization, the act of serializing access to critical sections of code, at various moments during their executions. For example, say that two threads need to send data packets over a single network connection. Each thread must be able to send its entire data packet before the other thread starts sending its data packet; otherwise, the data is scrambled. This scenario requires each thread to synchronize its access to the code that does the actual data-packet sending.
Correctly synchronizing threads is one of the more challenging thread-related skills for Java developers to master. This article begins a two-part series that attempts to meet that challenge by exploring the fundamentals of Java's synchronization capabilities. It begins by introducing you to the concepts of monitors and locks. You next will learn how synchronized methods and synchronized statements implement those concepts at the language level. Finally, you will learn about deadlock, a nasty problem that often occurs when synchronizing threads.
Note: This series assumes that you have a basic understanding of threads and
the java.lang.Thread class, such as how to start a thread. Also,
keep in mind that the idea of multiple threads simultaneously executing code
has slightly different meanings on uniprocessor and multiprocessor machines.
On a uniprocessor machine, threads don't actually run at the same time. The
thread scheduler gives the illusion that this is the case. In contrast, where
enough processors exist to run all eligible threads, a multiprocessor machine
is capable of actually running threads simultaneously.
Sun's Java virtual machine specification states that synchronization is based
on monitors. This point is reinforced at the Java VM level by the presence of
monitorenter and monitorexit instructions.
First suggested by E. W. Dijkstra in 1971, conceptualized by P. Brinch Hansen in 1972-1973, and refined by C. A. R. Hoare in 1974, a monitor is a concurrency construct that encapsulates data and functionality for allocating and releasing shared resources (such as network connections, memory buffers, printers, and so on). To accomplish resource allocation or release, a thread calls a monitor entry (a special function or procedure that serves as an entry point into a monitor). If there is no other thread executing code within the monitor, the calling thread is allowed to enter the monitor and execute the monitor entry's code. But if a thread is already inside of the monitor, the monitor makes the calling thread wait outside of the monitor until the other thread leaves the monitor. The monitor then allows the waiting thread to enter. Because synchronization is guaranteed, problems such as data being lost or scrambled are avoided. To learn more about monitors, study Hoare's landmark paper, ""Monitors: An Operating System Structuring Concept," first published by the Communications of the Association for Computing Machinery Inc. in 1974.
The Java virtual machine specification goes on to state that monitor behavior can be explained in terms of locks. Think of a lock as a token that a thread must acquire before a monitor allows that thread to execute inside of a monitor entry. That token is automatically released when the thread exits the monitor, to give another thread an opportunity to get the token and enter the monitor.
Java associates locks with objects: each object is assigned its own lock, and each lock is assigned to one object. A thread acquires an object's lock prior to entering the lock-controlled monitor entry, which Java represents at the source code level as either a synchronized method or a synchronized statement.
Note: Java 1.5 introduces the java.util.concurrent.locks package.
This package includes a Lock interface for implementing locking
operations that are more extensive than those offered by synchronized methods
and synchronized statements.
A synchronized method is a method that serves as a monitor entry. That
method's signature is prefixed with keyword synchronized and the
entire method's code is a critical section (a section of code that can
be executed by only one thread at a time, to prevent data loss or corruption).
The code fragment below presents a class with two instance-based synchronized
methods and one class-based synchronized method:
public class SyncMethods
{
public synchronized void instanceMethod1 ()
{
// Appropriate method-related code.
}
public synchronized void instanceMethod2 ()
{
// Appropriate method-related code.
}
public static synchronized void classMethod ()
{
// Appropriate method-related code.
}
}
A SyncMethods object reference is needed to invoke either of the
two synchronized instance methods. For example:
SyncMethods sm = new SyncMethods ();
sm.instanceMethod1 ();
The thread that executes sm.instanceMethod1() needs to acquire the
lock associated with the SyncMethods object that sm
references before it can enter that monitor entry. If some other thread has
that lock, because it is executing code inside of instanceMethod1()
or inside of instanceMethod2(), the invoking thread is made to wait
until the other thread leaves its instance method.
Because the static classMethod() does not require an object reference prior
to its invocation, which lock does a thread acquire before it can enter that
method? The answer is simple. When a classloader loads a class, the
classloader creates a java.lang.Class object that describes the
loaded class. A thread acquires the lock from that Class object.
For example, in SyncMethods.classMethod ();, the thread acquires
the lock from the SyncMethods Class object.
The lock assigned to a SyncMethods object and the lock assigned
to the SyncMethods Class object are two different
locks. As a result, one thread may execute inside of either instance method,
while a second thread simultaneously executes inside of the class method.
In contrast to the synchronized method, a synchronized statement is a
monitor entry with a (usually) smaller critical section. The statement begins
with the keyword synchronized, continues with an object identifier
placed between a pair of round bracket characters, and concludes with a block
of statements that serves as a critical section. The following code fragment
illustrates the synchronized statement:
public class SyncStatements
{
public void instanceMethod1 ()
{
// Setup code.
synchronized (this)
{
// Update file.
}
// Cleanup code.
}
public void instanceMethod2 ()
{
// Setup code.
synchronized (this)
{
// Read from file.
}
// Cleanup code.
}
}
Let's assume instanceMethod1() is invoked by a thread that must
periodically update a file and instanceMethod2() is invoked by a
different thread whenever it needs to read from that file. In addition to the
actual file I/O code, each method contains extensive setup and cleanup code,
which we'll assume is completely independent of the other method's setup and
cleanup code. Assume also that there are no interdependencies.
Because the setup and cleanup code is independent, the simultaneous execution
of both methods' setup codes or both methods' cleanup codes does not cause
data corruption, and synchronization isn't required. Since at least part of
each method doesn't need to be synchronized, there is no point in
synchronizing the entire method. But threads cannot simultaneously update a
file and read from that same file. Therefore, each method's appropriate file
I/O code is placed within a synchronized statement. When one thread tries to
invoke instanceMethod1()'s update file code, it must first
acquire the lock that associates with the current object (that keyword
this signifies). Similarly, when the other thread tries invoking
instanceMethod2()'s read file code, that thread must acquire the
same lock. Only one thread will succeed (if both threads simultaneously try
acquiring the lock) and the file will be updated or read from, depending on
which thread obtains the lock.
Sometimes, it is convenient to use both synchronized methods and synchronized statements in the same code. For example, consider a (not necessarily graphical) component's event registration and listener notification logic, as presented by the following code fragment:
// This code fragment has been shortened for
// clarity.
Vector clients = new Vector ();
public synchronized void addClientsListener
(ClientsListener cl)
{
clients.add (cl);
}
public synchronized void removeClientsListener
(ClientsListener cl)
{
clients.remove (cl);
}
private void notifyClients ()
{
// ... Preamble.
Vector copy;
synchronized (this)
{
copy = (Vector) clients.clone ();
}
for (int i = 0; i < copy.size (); i++)
// Retrieve listener object from copy
// Vector at index i and fire an event
// object to that listener, by invoking
// a specific listener method with the
// event object as an argument to that
// method.
}
The code fragment above presents a clients data structure, two
synchronized methods for registering and de-registering listener objects (via
that data structure), and a private method that incorporates a synchronized
statement to assist in notifying those listeners when something interesting
happens. Both synchronized methods and the synchronized statement obtain the
same lock from the current (this) object. The result: only one
thread may execute, at any moment in time, inside of one of the three critical
sections.
Why is a synchronized statement used to make a copy of the data structure? To
understand the need for that statement, assume the thread that invokes either
of the synchronized methods, to register or de-register a client's interest in
receiving event notifications, is separate from the thread that invokes
notifyClients(). This is often the case in practice. Also,
assume that notifyClients()'s synchronized statement was not
present and that its for loop worked with clients directly. In that situation,
suppose a listener's thread removes the listener from the clients
data structure, while the for loop is iterating. At that point, the number of
objects in the Vector is less than the number of iterations that
the for loop must complete (based on initially evaluating expression
i < clients.size ()). The result: a runtime exception (caused by
an illegal array index) is thrown.
Pages: 1, 2 |
View all java.net Articles.
|
|