|
Welcome to the Core Java Technologies Tech Tips for February 16, 2005. Here you'll get tips on using core Java technologies and APIs, such as those in Java 2 Platform, Standard Edition (J2SE).
This issue covers:
Getting to Know Synchronizers
HotSpot Garbage Collection Configuration Options
Errata
These tips were developed using the Java 2 Platform Standard Edition Development Kit 5.0 (JDK 5.0). You can download JDK 5.0 at http://java.sun.com/j2se/1.5.0/download.jsp.
This issue of the Core Java Technologies Tech Tips is written by John Zukowski, president of JZ Ventures, Inc..
See the Subscribe/Unsubscribe note at the end of this newsletter to subscribe to Tech Tips that focus on technologies and products in other Java platforms.
For more Java technology content, visit these sites:
java.sun.com - The latest Java platform releases, tutorials, and newsletters.
java.net - A web forum for collaborating and building solutions together.
java.com - The marketplace for Java technology, applications and services.
GETTING TO KNOW SYNCHRONIZERS
Support for synchronization has been part of the Java programming language since the initial release of the Java
platform. This support is designed to prevent simultaneous access to critical code blocks and shared variables.
Synchronization gives you the choice of adding the synchronized keyword to a method or wrapping a code section in a synchronized
block. J2SE 5.0 adds several mechanisms for coordinating between different threads in an application. This added support is
provided by the new java.util.concurrent package. The package includes classes that offer semaphores, barriers, latches, and
exchangers -- this tip looks at each of these in more detail.
Class Semaphore
Wikipedia, defines a semaphore as follows:
A semaphore is a protected variable (or abstract data type) and constitutes the classic method for restricting access to shared resources (e.g. storage) in a multi-processing environment.
In other words, where a synchronized block allows only one entity to access a shared resource, a semaphore allows 'n' entities to access the resource. As an example, imagine going to the bank. Imagine too that there are few tellers, so that the
line waiting to make a bank transaction is rather long. Think of the number of available tellers (that is, tellers who are currently not handling a customer) as the semaphore count. When the count is zero, no one new can engage with a teller. When
a teller becomes available, the count increases, and another customer in the bank line can approach the teller. After the teller is engaged with a customer, the count is reduced. If no one is waiting in the bank line, the count remains above zero.
Typically in a bank, the people waiting in a queue are handled in a first-in first-out (FIFO) manner. However, not all queues proceed in this fair, equitable way. For instance, sometimes people jump ahead in the line for various reasons.
Before going any further, it's helpful to define three synchronization-related terms: lock, permit, and block. A lock
is a construct for controlling access to a shared resource by multiple threads. Commonly, a lock provides exclusive access to
a shared resource -- only one thread at a time can acquire the lock and all access to the shared resource requires that the
lock be acquired first. A permit is like a ticket or token to a lock that is associated with a semaphore. A semaphore
maintains a set of permits typically for a restricted pool of resources. A thread must acquire a permit from the semaphore
before it can obtain a resource from the pool. When the thread finishes with the resource, it returns it back to the pool. The
permit is then returned to the semaphore, allowing another thread to acquire that resource. A block signifies that a thread
is waiting for a required lock or permit before it can access a shared resource.
With those definitions in mind, let's look at the new Semaphore class, the class in J2SE 5.0 that offers support for semaphores. You create the class with one of the following two constructors:
Semaphore(int permits)
Semaphore(int permits, boolean fair)
Unless specified, the created Semaphore has a "non-fair" setting. This means that FIFO behavior is not guaranteed. To prevent starvation (a thread never getting a resource), it is good practice to always create fair semaphores. However, there are throughput advantages if you ignore fairness.
To acquire a permit from a semaphore, you call one of the following methods:
acquire()
acquire(int permits)
The acquire() signature of the method gets a single permit. The acquire(int permits) signature of the method gets the requested
number of permits. The methods block until a permit is available, or until the waiting thread is interrupted. If the
thread is interrupted, an InterruptedException is thrown.
The next two methods also acquire permits, but their threads are dormant and uninterruptible until the required number of permits
are available. If a thread is interrupted while waiting, it is discovered after the resource becomes available.
acquireUninterruptibly()
acquireUninterruptibly(int permits)
The tryAcquire methods do not block. If the required number of permits are available, the methods return true. If the permits
are not available, the methods return false. All permits must be available for true to be returned. The fairness setting is
ignored with these untimed tryAcquire methods.
tryAcquire()
tryAcquire(int permits)
The timed versions work similarly to the untimed version, but wait for the specified TimeUnit before returning. If interrupted
while waiting, the methods throw an InterruptedException.
tryAcquire(long timeout, TimeUnit unit)
tryAcquire(int permits, long timeout, TimeUnit unit)
The following line shows how to wait 30 seconds for one permit:
boolean acq = tryAcquire(30, TimeUnit.SECONDS)
Now let's look at an example that uses the Semaphore class. The following program, SemaphoreTest, simulates a service provided
by an online auction house. The service allows customers to sample multiple items available for purchase, and displays an
average of the prices of these items. However due to licensing limitations, only two threads can access the service at a time.
To simulate the price sampling and averaging, the SemaphoreTest program waits 50 milliseconds, and then returns a random number
from 0-to-100. If the service is concurrently accessed after the limit of two threads is reached, a second pricing scheme is used.
This latter scheme doesn't have the licensing restriction, but returns a less-reliable price (in this example, $20).
Here is the SemaphoreTest program:
import java.util.concurrent.*;
import java.util.*;
public class SemaphoreTest {
private static final int LOOP_COUNT = 100;
private static final int MAX_AVAILABLE = 2;
private final static Semaphore semaphore =
new Semaphore(MAX_AVAILABLE, true);
private static class Pricer {
private static final Random random = new Random();
public static int getGoodPrice() {
int price = random.nextInt(100);
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
return price;
}
public static int getBadPrice() {
return 20;
}
}
public static void main(String args[]) {
for (int i=0; i<LOOP_COUNT; i++) {
final int count = i;
new Thread() {
public void run() {
int price;
if (semaphore.tryAcquire()) {
try {
price = Pricer.getGoodPrice();
} finally {
semaphore.release();
}
} else {
price = Pricer.getBadPrice();
}
System.out.println(count + ": " + price);
}
}.start();
}
}
}
Depending on the speed of your machine, you might need to modify the 50 millisecond delay to get reasonable results. "Reasonable
results" means that you get some random values generated, and some $20 values.
One sample run follows. Your results might differ.
2: 20
3: 20
4: 20
5: 20
...
25: 20
26: 20
27: 20
0: 0
1: 4
30: 20
31: 20
32: 20
33: 20
...
43: 20
44: 20
28: 84
29: 6
48: 20
49: 20
50: 20
...
85: 20
86: 20
87: 20
88: 20
89: 20
46: 41
47: 55
93: 20
94: 20
95: 20
96: 20
97: 20
98: 20
99: 20
91: 4
92: 64
Notice how the results for pass 0 and 1 don't appear until after many other results. You then get more 20s before seeing more
"good" results. Each call to the average pricing service takes time, therefore the interspersed results.
By going through a semaphore, the program effectively controls access to the limited resource.
Semaphores can also be used for controlling access to other pooled resources, such as connection pools. With a connection
pool, there might be several open sockets. After passing through a semaphore lock, different threads would get access to
the open sockets.
Class CyclicBarrier
The next synchronization aid added in J2SE 5.0 is the barrier. A barrier offers a common point (called a barrier point) for
a set of threads to wait for each other before continuing their execution. The class that provides this function is called
CyclicBarrier because after one set of threads passes through the barrier point, the next set can arrive without creating a new
barrier.
A common example used to demonstrate barriers is the splitting of a task into subtasks or segments, such that each subtask or
segment can be executed separately. For example, imagine a two-dimensional array or matrix, and you want the sum of all
the values in the matrix. If you have access to a multi-processor system, it might be faster to split the matrix
in two halves and have each processor sum up the values in its half. Then, when both sums are done, you add the sums together,
and return the combined total. That's exactly what you would use a barrier for. The barrier prevents the final summation
until the sums for both halves are calculated.
The CyclicBarrier class allows you to create a barrier in one of two ways. You can create a barrier with a number of parties
(threads), or with a number of parties and a barrier action. A barrier action is a Runnable task that executes when all the
threads have joined together, but before the threads are released to run on their own.
CyclicBarrier(int parties)
CyclicBarrier(int parties, Runnable barrierAction)
After each of the parties finishes its individual task, the party joins the barrier by calling its await method. This blocks
that party until all parties are present. If a barrier action is provided, it then runs, before releasing all the parties.
To demonstrate, the following program, Summary, performs a parallel summation of the elements of a triangular matrix:
import java.util.concurrent.*;
public class Summary {
private static int matrix[][] = {
{1},
{2, 2},
{3, 3, 3},
{4, 4, 4, 4},
{5, 5, 5, 5, 5}
};
private static int results[];
private static class Summer extends Thread {
int row;
CyclicBarrier barrier;
Summer(CyclicBarrier barrier, int row) {
this.barrier = barrier;
this.row = row;
}
public void run() {
int columns = matrix[row].length;
int sum = 0;
for (int i=0; i<columns; i++) {
sum += matrix[row][i];
}
results[row] = sum;
System.out.println(
"Results for row " + row + " are : " + sum);
// wait for others
try {
barrier.await();
} catch (InterruptedException ex) {
ex.printStackTrace();
} catch (BrokenBarrierException ex) {
ex.printStackTrace();
}
}
}
public static void main(String args[]) {
final int rows = matrix.length;
results = new int[rows];
Runnable merger = new Runnable() {
public void run() {
int sum = 0;
for (int i=0; i<rows; i++) {
sum += results[i];
}
System.out.println("Results are: " + sum);
}
};
CyclicBarrier barrier = new CyclicBarrier(rows, merger);
for (int i=0; i<rows; i++) {
new Summer(barrier, i).start();
}
System.out.println("Waiting...");
}
}
Compile and run the Summary program. You should get results similar to the following:
Results for row 0 are : 1
Results for row 1 are : 4
Results for row 2 are : 9
Waiting...
Results for row 3 are : 16
Results for row 4 are : 25
Results are: 55
Note that the results of different runs can vary depending on the underlying thread scheduling algorithm of the platform.
Creating a new thread and scheduling it, doesn't necessarily cause the system to pause the existing thread. Multiple threads
might be created, all with the same priority. The system simply picks one thread to schedule at that priority level.
If you wish to reuse a cyclic barrier, you can call the reset method of CyclicBarrier between usages.
Class CountDownLatch
The next synchronization control is called a latch. Latching variables specify conditions that once set never change. This
provides a way to start several threads and have them wait until a signal is received from a coordinating thread. Latching
works well with initialization tasks, where you want no process to run until everything needed is initialized. Latching also
works well for multiplayer games, where you don't want any player to start until all players have joined.
The CountDownLatch class encapsulates a latch that is initialized with a given count. The constructor takes the count
as its one argument. The count works in a way similar to the parties argument to the CyclicBarrier constructor. It indicates
how many times a countDown method must be called, one for each party involved. After the full count is reached, any threads
waiting because of an await method are released.
The following program, LatchTest, demonstrates the use of a CountDownLatch. The program creates a set of threads, but
doesn't let any thread start until all the threads are created.
import java.util.concurrent.*;
public class LatchTest {
private static final int COUNT = 10;
private static class Worker implements Runnable {
CountDownLatch startLatch;
CountDownLatch stopLatch;
String name;
Worker(CountDownLatch startLatch,
CountDownLatch stopLatch, String name) {
this.startLatch = startLatch;
this.stopLatch = stopLatch;
this.name = name;
}
public void run() {
try {
startLatch.await();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("Running: " + name);
stopLatch.countDown();
}
}
public static void main(String args[]) {
CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch stopSignal = new CountDownLatch(COUNT);
for (int i=0; i<COUNT; i++) {
new Thread(
new Worker(startSignal, stopSignal,
Integer.toString(i))).start();
}
System.out.println("Go");
startSignal.countDown();
try {
stopSignal.await();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("Done");
}
}
The main thread awaits on a second latch to make sure that the other threads are finished. This example could have simply used a join operation on the created threads, however imagine if you
needed to synchronize threads at different phases through their lifetime, instead of just at the end.
Compile and run LatchTest. Your output should look similar to the following:
Go
Running: 0
Running: 1
Running: 2
Running: 3
Running: 4
Running: 5
Running: 6
Running: 7
Running: 8
Running: 9
Done
Feel free to adjust the earlier Summary class to use a CountDownLatch to make sure that all threads are done before continuing.
Class Exchanger<V>
The Exchanger is the last of the synchronization utilities covered in this tip. It offers a simplified way of communicating
between threads, that is, by passing a specific object between two threads. That's why there's the <V> after the class name.
Instead of using Piped streams for stream-based, inter-thread communication (where one side writes and the other reads), the
code>Exchanger relies on a single exchange method for the transfer of one-off data between threads. The Exchanger is not a general replacement for the piped model, but their usages are similar.
The Exchanger constructor takes no arguments here, other than identifying the type of object to exchange:
Exchanger<List<Integer>> exchanger =
new Exchanger<List<Integer>>();
To exchange objects, you call the exchange method. The method transfers objects in both directions, not just one:
V exchange(V x)
An Exchanger is often used when you have two threads, one consuming a resource, and the other producing it. When the
buffer used by the producer is full, the producer waits for the consumer. When the buffer used by the consumer is empty, the
consumer waits for the producer. After both waits happen, the two threads swap buffers. This works well when you know more
items will be produced. Otherwise, items will sit waiting for a full buffer before swapping buffers.
Here's an example that uses the Exchanger. The FillingLoop class is the producer type. The EmptyingLoop class is the
consumer. When the producer's data structure is full, it tries to exchange with the consumer. When the consumer's data
structure is empty, it tries to exchange data structures with the producer. After both the producer and consumer are waiting,
the exchange happens.
import java.util.*;
import java.util.concurrent.*;
public class ExchangerTest {
private static final int FULL = 10;
private static final int COUNT = FULL * 20;
private static final Random random = new Random();
private static volatile int sum = 0;
private static Exchanger<List<Integer>> exchanger =
new Exchanger<List<Integer>>();
private static List<Integer> initiallyEmptyBuffer;
private static List<Integer> initiallyFullBuffer;
private static CountDownLatch stopLatch =
new CountDownLatch(2);
private static class FillingLoop implements Runnable {
public void run() {
List<Integer> currentBuffer = initiallyEmptyBuffer;
try {
for (int i = 0; i < COUNT; i++) {
if (currentBuffer == null)
break; // stop on null
Integer item = random.nextInt(100);
System.out.println("Added: " + item);
currentBuffer.add(item);
if (currentBuffer.size() == FULL)
currentBuffer =
exchanger.exchange(currentBuffer);
}
} catch (InterruptedException ex) {
System.out.println("Bad exchange on filling side");
}
stopLatch.countDown();
}
}
private static class EmptyingLoop implements Runnable {
public void run() {
List<Integer> currentBuffer = initiallyFullBuffer;
try {
for (int i = 0; i < COUNT; i++) {
if (currentBuffer == null)
break; // stop on null
Integer item = currentBuffer.remove(0);
System.out.println("Got: " + item);
sum += item.intValue();
if (currentBuffer.isEmpty()) {
currentBuffer =
exchanger.exchange(currentBuffer);
}
}
} catch (InterruptedException ex) {
System.out.println("Bad exchange on emptying side");
}
stopLatch.countDown();
}
}
public static void main(String args[]) {
initiallyEmptyBuffer = new ArrayList<Integer>();
initiallyFullBuffer = new ArrayList<Integer>(FULL);
for (int i=0; i<FULL; i++) {
initiallyFullBuffer.add(random.nextInt(100));
}
new Thread(new FillingLoop()).start();
new Thread(new EmptyingLoop()).start();
try {
stopLatch.await();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("Sum of all items is.... " + sum);
}
}
The filling and emptying loops run a fixed number of times before stopping. They use a CountDownLatch to tell the main thread when they're done.
The output includes sections of "Added" and "Got" values that should match, though the actual numbers might differ in your
runs.
Added: 12
Added: 64
Added: 49
Added: 22
Added: 13
Added: 53
Added: 26
Added: 37
Added: 87
Added: 30
Got: 12
Got: 64
Got: 49
Got: 22
Got: 13
Got: 53
Got: 26
Got: 37
Got: 87
Got: 30
...
Sum of all items is.... 9522
The java.util.concurrent package includes many useful utilities
for concurrent programming. The semaphores, barriers, latches, and exchangers are just four of the utilities available that are
now a standard part of J2SE 5.0. To learn more about concurrent programming, see the book Concurrent Programming in Java: Design
Principles and Patterns. Expect a third edition that describes the 5.0 features.
HOTSPOT GARBAGE COLLECTION CONFIGURATION OPTIONS
The April 20, 2004 Tech Tip Garbage Collection and You,
explored various non-standard options for the java command line tool (the Java application launcher) that control garbage
collection in the runtime environment. In J2SE 5.0, several of these options have changed and more controls have been added.
This tip briefly covers some of these changes and additions, and points to other places for further information. If you're
unfamiliar with HotSpot options for garbage collection, you might start by reading the "Garbage Collection and You" Tech Tip
first.
The HotSpot command line options are described on the Java HotSpot VM Options page. However, this
page hasn't been updated since 2002. A good place to learn about some of the 5.0 specifics is the technical article Tuning
Garbage Collection with the 5.0 Java Virtual Machine*. Not only does the Tuning Garbage Collection document cover a number of the new or updated options, but it more fully
describes the topic of tuning garbage collection with the 5.0 JVM.
Another source of information on available options is a web log from Joseph Mocker of Sun Microsystems.
The blog includes all the command-line options found by the author, not only those related to garbage collection. And, of
course, another place to look is the source code itself, available under the Java Research License through java.net.
The HotSpot compiler offers multiple options for configuring the model to use for garbage collection. The default is the serial
collector that was available in the prior version of the J2SE platform. It only uses a single thread of control (that is,
a single CPU). However, J2SE 5.0 makes available the following other models: throughput collector, concurrent low pause
collector, and incremental low pause collector.
Unlike the serial collector, the throughput generational collector runs as multiple threads across several CPUs. To
specify the throughput collector, use the -XX:+UseParallelGC option. The concurrent low pause collector
executes concurrently to the execution of the application and is specified with either the -Xincgc or -XX:+UseConcMarkSweepGC
options. The incremental low pause collector is no longer actively developed, but is still available. To use the
incremental low pause collector, specify the -XX:+UseTrainGC command line option (note that this option will
not be available in J2SE 6.0).
The adaptive size policy of the throughput garbage collector includes performance tuning options and new options to configure
the goals of the garbage collector. Each of these options and its purpose follows:
-XX:AdaptiveSizeDecrementScaleFactor=VALUE.
Sets the adaptive size scale-down factor for shrinking. The default is 4.
-XX:AdaptiveSizePolicyCollectionCostMargin=VALUE
If collection costs are within margin, reduce both by full delta. The default is 50.
-XX:AdaptiveSizePolicyInitializingSteps=VALUE
Specifies the number of steps to use heuristics before real data is used. The default is 20.
-XX:AdaptiveSizeThroughPutPolicy=VALUE
Specifies the policy for changing generation size for throughput goals. The default is 0.
There are many more options available to configure the adaptive size policy of the throughput garbage collector. These are just
some of the newer settings, available in J2SE 5.0.
Several new options control the concurrent low pause collector, also knows as "concurrent mark and sweep." As the following
settings show, these typically include "CMS" in the prefix of the option name, though not all existing options start with CMS.
These new options control the timing and size of garbage collection passes and available memory. The concurrent low pause collector is used with the XX:+UseConcMarkSweepGC option.
-XX:CMSAbortablePrecleanMinWorkPerIteration=VALUE
Sets the nominal minimum work per abortable preclean iteration. The default is 100.
-XX:CMSAbortablePrecleanWaitMillis=VALUE
Specifies in milliseconds the time to sleep between iterations when not given enough work per iteration. The default is 50.
-XX:CMSBootstrapOccupancy=VALUE
Specifies the percentage CMS generation occupancy at which to initiate CMS collection for bootstrapping collection stats. The default is 50.
-XX:CMSMarkStackSizeMax=VALUE
Specifies the maximum size of the CMS marking stack. The default is 4 MB.
-XX:CMSMaxAbortablePrecleanLoops=VALUE
When value is greater than 0, this sets the maximum number of abortable preclean iterations. The default is 0.
-XX:CMSMaxAbortablePrecleanTime=VALUE
Specifies in milliseconds the maximum time in abortable preclean states. The default is to 1000 or 1 second.
-XX:+CMSPrecleanRefLists1
Sets the preclean reference lists during the (initial) preclean phase. The default is true.
-XX:+CMSPrecleanRefLists2
Sets the preclean reference lists during abortable preclean phase. The default is false.
-XX:CMSSamplingGrain=VALUE
Specifies the minimum distance between Eden samples for CMS. The default is 16K.
-XX:CMSScheduleRemarkEdenPenetration=VALUE
Specifies the Eden occupancy percentage at which to try and schedule remark pause. The default is 50%.
-XX:CMSScheduleRemarkEdenSizeThreshold=VALUE
Sets the Eden size threshold for scheduling remark If the Eden used is below this value, don't try to schedule remark. The default is 2 MB.
-XX:CMSScheduleRemarkSamplingRatio=VALUE
Start sampling Eden top at least before young generation occupancy reaches 1/[ratio] of the size for a planned schedule remark. The default is 5.
For a deeper look at the concurrent low pause collector, see the paper A Generational Mostly-concurrent Garbage Collector. There are many other configurable settings for this collection model.
The parallel garbage collector offers two new options:
-XX:ParGCArrayScanChunk=VALUE
-XX:+ParallelGCRetainPLAB
Additional new command line options are not specific to the chosen collector. As hints to the garbage collector, you can
request that times be limited to a certain number of milliseconds. The virtual machine will automatically adjust the
heap times in an attempt to keep times within the specified maximums.
-XX:MaxGCMinorPauseMillis=VALUE
-XX:MaxGCPauseMillis=VALUE
Another option controls the ratio of garbage collection time to throughput time:
-XX:GCTimeRatio=VALUE
For instance, a GCTimeRatio setting of 19 would be 5% for GC and 95% for throughput, following the function 1/(1 + VALUE). These
last three options are accepted by all the collectors, but only do something meaningful in the -XX:+UseParallelGC collector. Try
tweaking the values to determine the kind of garbage collector pauses you can tolerate and the fraction of time you are willing to devote to collection.
You can further control the heap size through the following new options:
-XX:DefaultInitialRAMFraction=VALUE
-XX:DefaultMaxRAM=VALUE
-XX:DefaultMaxRAMFraction=VALUE
DefaultInitialRAMFraction has a default value of 64. DefaultMaxRAMFraction has a default value of 4. The default initial size of the heap is the size of physical RAM divided by
DefaultInitialRAMFraction. You can either set the maximum size explicitly with DefaultMaxRAM or use DefaultMaxRAMFraction to set the value proportionally.
Yet another optional command-line flag that's new in J2SE 5.0, though not specific to garbage collection, is one for a fatal
error handler. The flag allows you to specify a program to run in case of a fatal error, and pass the JVM process to that
program. You request the fatal error handler through the setting -XX:OnError=VALUE. For example, the following launches the gdb
debugger in response to a fatal error:
-XX:OnError="gdb %p"
The optional %p argument represents the process id.
There are many other new options available for HotSpot and the Java Virtual Machine. You can find additional information in the
Java Virtual Machine documentation for 5.0. Also the technical article Turbo-charging Java HotSpot Virtual Machine, v1.4.x to Improve the Performance and Scalability of Application
Servers offers details related to the 1.4.x VM, which are mostly still relevant.
ERRATA
Last month's Tech Tip on "Customizing Window Adornments" included the wrong image for the discussion of a JOptionPane with a
WARNING_DIALOG style. You can see the correct figure at http://java.sun.com/developer/JDCTechTips/2005/tt0118.html#2.
|
|
 |
 |
|
|
 |
 |
IMPORTANT: Please read our Licensing, Terms of Use, and Privacy policies:
http://developer.java.sun.com/berkeley_license.html
http://www.sun.com/share/text/termsofuse.html
Privacy Statement: Sun respects your online time and privacy (http://sun.com/privacy). You have received this based on your email preferences. If you would prefer not to receive this information, please follow the steps at the bottom of this message to unsubscribe.
Comments? Send your feedback on the Core Java Technologies Tech Tips to: http://developers.sun.com/contact/feedback.jsp?category=newslet
Subscribe to other Java developer Tech Tips:
- Enterprise Java Technologies Tech Tips. Get tips on using enterprise Java technologies and APIs, such as those in the Java 2 Platform, Enterprise Edition (J2EE).
- Wireless Developer Tech Tips. Get tips on using wireless Java technologies and APIs, such as those in the Java 2 Platform, Micro Edition (J2ME).
To subscribe to these and other Sun Developer Network publications:
- Go to the Sun Developer Network Subscriptions page, choose the newsletters you want to subscribe to and click
"Submit".
- To unsubscribe, go to the subscriptions page, uncheck the appropriate checkbox, and click "Submit".
ARCHIVES: You'll find the Core Java Technologies Tech Tips archives at:
http://java.sun.com/developer/JDCTechTips/index.html
Copyright 2005 Sun Microsystems, Inc. All rights reserved.
4150 Network Circle, Santa Clara, CA 95054 USA.
This document is protected by copyright. For more information, see:
http://java.sun.com/developer/copyright.html
Java, J2SE, J2EE, J2ME, and all Java-based marks are trademarks or registered trademarks (http://www.sun.com/suntrademarks/) of Sun Microsystems, Inc. in the United States and other countries.
|