Table of contents
I'd like to share some best practices to help you effectively use the Java programming language and its fundamental libraries.
This article has 4 parts:
General programming: how to use variables, control structures, libraries, data types,... effectively.
Lambdas and Streams: how to make the best use of functional interfaces, lambdas, and method references.
Exceptions: guidelines for using exceptions effectively.
Concurrency: write clear and correct concurrent programs.
Ok, let's go!!!
I. General Programming
a. Prefer for-each loops to traditional for loops
for-each loops get rid of the clutter and the opportunity for error by hiding the iterator or index variable. There is no performance penalty for using for-each
However, there are some common situations where you can’t use for-each:
need the array index in order to do something
need to traverse multiple collections in parallel, then you need explicit control over the iterator or index variable
b. Prefer primitive types to boxed primitives
Primitives are more time and space efficient than boxed primitives
Applying the == operator to boxed primitives is almost always wrong => be careful when comparing boxed primitives.
When you mix primitives and boxed primitives in an operation, the boxed primitive is auto-unboxed. If a null object reference is auto-unboxed, you get a NullPointerException
Repeatedly boxed and unboxed causing performance degradation.
c. Avoid Strings where other types are more appropriate
If it’s numeric, it should be translated into the appropriate numeric type, such as int, long, float, or BigInteger
If it’s the answer to a yes-or-no question, it should be translated into an appropriate enum type or a boolean
d. Beware the performance of string concatenation
Strings are immutable, so when two strings are concatenated, the contents of both are copied and Java creates a new String
To achieve acceptable performance, use a StringBuilder in place of a String
e. Refer to objects by their interfaces
If appropriate interface types exist, then parameters, return values, variables, and fields should all be declared using interface types
Your program will be much more flexible to switch implementations. However, it is entirely appropriate to refer to an object by a class rather than an interface if no appropriate interface exists. If there is no appropriate interface, just use the least specific class in the class hierarchy that provides the required functionality**.**
II. Lambdas and Streams
a. Prefer lambdas to anonymous classes
In Java 8, the language formalized the notion that interfaces with a single abstract method are special and deserve special treatment. These interfaces are now known as functional interfaces, and the language allows you to create instances of these interfaces using lambda expressions, or lambdas for short.
However, lambdas lack names and documentation. If a computation isn’t self-explanatory or exceeds a few lines, don’t put it in a lambda. One line is ideal for lambda, and three lines are a reasonable maximum.
b. Prefer method references to lambdas
Java provides a way to generate function objects even more succinct than lambdas: method references
However, where method references are shorter and clearer, use them; where they aren’t, stick with lambdas.
c. Favor the use of standard functional interfaces
If one of the standard functional interfaces does the job, you should generally use it in preference to a purpose-built functional interface.
Of course you need to write your own if none of the standard ones does what you need, for example if you require a predicate that takes three parameters.
d. Use streams judiciously
Overusing streams makes programs hard to read and maintain.
In the absence of explicit types, careful naming of lambda parameters is essential to the readability of stream pipelines.
Using helper methods is important for readability in stream pipelines.
f. Prefer Collection to Stream as a return type
When writing a method that returns a sequence of elements, remember that some of your users may want to process them as a stream while others may want to iterate over them
\=> Collection or an appropriate subtype is generally the best return type
g. Use caution when making streams parallel
Java 8 introduced streams, which can be parallelized with a single call to the parallel method.
Parallelizing a pipeline is unlikely to increase its performance if the source is from Stream.iterate, or the intermediate operation limit is used
Performance gains from parallelism are best on streams over ArrayList, HashMap, HashSet, and ConcurrentHashMap instances; arrays; int ranges; and long ranges. What these data structures have in common is that they can all be accurately and cheaply split into subranges of any desired sizes, which makes it easy to divide work among parallel threads
Do not even attempt to parallelize a stream pipeline unless you have good reason to believe that it will preserve the correctness of the computation and increase its speed
a. Use checked exceptions for recoverable conditions and runtime exceptions for programming errors
Use checked exceptions for conditions from which the caller can reasonably be expected to recover.
Ex: For example, suppose a checked exception is thrown when an attempt to make a purchase with a gift card fails due to insufficient funds. The exception should provide an accessor method to query the amount of the shortfall. This will enable the caller to relay the amount to the shopper.
If a program throws an unchecked exception or an error, it is generally the case that recovery is impossible and continued execution would do more harm than good.
Ex: ArrayIndexOutOfBoundsException, NullPointerException
If it isn’t clear whether recovery is possible, you’re probably better off using an unchecked exception
b. Avoid unnecessary use of checked exceptions
Overuse of checked exceptions places a burden on the user of the API
If callers won’t be able to recover from failures, throw unchecked exceptions. If recovery may be possible and you want to force callers to handle exceptional conditions, first consider returning an optional.
However, if optional provides insufficient information in the case of failure, you should throw a checked exception.
c. Favor the use of standard exceptions
The Java libraries provide a set of exceptions that cover most of the exception-throwing needs of most APIs.
This table summarizes the most commonly reused exceptions:
Do not reuse Exception, RuntimeException, Throwable, or Error directly.
d. Throw exceptions appropriate to the abstraction
Higher layers should catch lower-level exceptions and, in their place, throw exceptions that can be explained in terms of the higher-level abstraction (exception translation)
e. Include failure-capture information in detail messages
- To capture a failure, the detail message of an exception should contain the values of all parameters and fields that contributed to the exception.
Ex: The detail message of an IndexOutOfBoundsException should contain the lower bound*, the* upper bound*, and the* index value that failed to lie between the bounds
- Do not include passwords, encryption keys,... in detail messages
f. Strive for failure atomicity
A failed method invocation should leave the object in the state that it was in prior to the invocation. There are several ways to achieve this effect:
Design immutable objects
For methods that operate on mutable objects ==> check parameters for validity before performing the operation (exceptions will be thrown before object modification commences)
g. Don't ignore exceptions
When the designers of an API declare a method to throw an exception, they are trying to tell you something. Don’t ignore it!
If you choose to ignore an exception, the catch block should contain a comment explaining why it is appropriate to do so, and the variable should be named ignored
a. Synchronize access to shared mutable data
The synchronized keyword ensures that only a single thread can execute a method or block at one time.
when multiple threads share mutable data, each thread that reads or writes the data must perform synchronization
without synchronization, there is no guarantee that one thread’s changes will be visible to another thread
volatile modifier performs no mutual exclusion, but guarantees that any thread that reads the field will see the most recently written value
However, you have to be careful when using volatile. For example, in the following method, the problem is that the increment operator (++) is not atomic. It performs two operations (volatile still works if performs one operation) on the nextSerialNumber field: first it reads the value, and then it writes back a new value:
If a second thread reads the field between the time a thread reads the old value and writes back a new one, the second thread will see the same value as the first and return the same serial number ==> computes the wrong results.
One way to fix it is to use AtomicLong.
The best way to avoid the problems discussed is not to share mutable data.
b. Avoid excessive synchronization
Excessive synchronization can cause reduced performance, deadlock,...
Inside a synchronized region, do not invoke a method that is designed to be overridden, or one provided by a client in the form of a function object
\=> has no knowledge of what the method does and has no control over it.
There are some ways to move the alien method invocations out of the synchronized block:
taking a “snapshot” of shared mutable data => safely traversed without a lock
use concurrent collections*: CopyOnWriteArrayList*, ConcurrentHashMap,...
As a rule, you should do as little work as possible inside synchronized regions! If you must perform some time-consuming activity, find a way to move it out of the synchronized region.
If you are writing a mutable class, you have two options: you can omit all synchronization and allow the client to synchronize externally if concurrent use is desired, or you can synchronize internally, making the class thread-safe
c. Prefer executors, tasks, and streams to threads
Instead of creating and managing threads manually, you can create a thread pool with a fixed or variable number of threads. I prefer ThreadPoolExecutor or ThreadPoolTaskExecutor (in SpringBoot) for a heavily loaded production server.
d. Prefer concurrency utilities to wait and notify
Instead of using wait and notify, you should use the higher-level concurrency utilities in java.util.concurrent package: Executor, concurrent collections and synchronizers.
use ConcurrentHashMap in preference to Collections.synchronizedMap
For interval timing, use System.nanoTime rather than System.currentTimeMillis
(more accurate and more precise and is unaffected by adjustments to the system’s real-time clock)
If you have to maintain legacy code that uses wait and notify, always use the wait loop idiom (inside a synchronized region) to invoke the wait method; never invoke it outside of a loop:
e. Use lazy initialization judiciously
The best advice for lazy initialization is “don’t do it unless you need to”. It decreases the cost of initializing a class or creating an instance, at the expense of increasing the cost of accessing the lazily initialized field.
Under most circumstances, normal initialization is preferable to lazy initialization.
If you must initialize a field lazily in order to achieve your performance goals or break a harmful initialization circularity:
For instance fields, use double-check idiom (singleton pattern)
For static fields, use lazy initialization holder class idiom
f. Don't depend on the thread scheduler
When many threads are runnable, the thread scheduler determines which ones get to run and for how long. Any reasonable operating system will try to make this determination fairly, but the policy can vary. Therefore, well-written programs shouldn’t depend on the details of this policy.
The best way to write a robust, responsive, portable program is to ensure that the average number of runnable threads is not significantly greater than the number of processors. This leaves the thread scheduler with little choice: it simply runs the runnable threads till they’re no longer runnable. Note that the number of runnable threads isn’t the same as the total number of threads, which can be much higher. Threads that are waiting are not runnable.
In terms of the Executor Framework, this means sizing thread pools appropriately and keeping tasks short, but not too short, or dispatching overhead will harm performance.
Phewww!!! I've just shared with you guys some important tips and tricks to work with Java efficiently. If you find it helpful, please share it with everyone to join hands in building a stronger tech community in Vietnam.