31 Plus Lesson 1 Take You Through Some Important Concepts of Java 8 Course Part One

31 Plus Lesson 1 Take You Through Some Important Concepts of Java 8 Course Part One #

Java 8 is currently the most commonly used version of JDK. It has added many features compared to Java 7, which enhance code readability and simplify coding. Some of these features include Lambda expressions, Stream operations, parallel streams (ParallelStream), Optional nullable types, and new date and time types.

All the examples in this course extensively utilize various features of Java 8 to simplify the code. This means that if you are not familiar with these features, understanding the demos in the course might be a bit challenging. Therefore, I have extracted these features separately to form two additional lessons. Since there is a dedicated lesson later to discuss Java 8’s date and time types, I will not go into detail about them here.

How to use Lambda expressions and Stream operations in a project? #

There are many features in Java 8. In addition to these two extra topics, I recommend you a comprehensive book on Java 8 called “Java 8 in Action (2nd Edition)”. Furthermore, someone asked in the comments about how to apply Lambda expressions and Stream operations to a project. In fact, there are many places in business code where these features can be used.

To help you learn and apply these features to business development, I have three suggestions.

First, start with operations on Lists, and try using the filter and map operations of Streams to filter and transform data while iterating through a List. These are the two most commonly used and basic APIs of Streams. You can focus on the content of the next two sections to get started.

Second, use advanced IDEs to write code and find places where you can simplify code using Java 8 language features. For example, in IntelliJ IDEA, we can set the inspection rule for replacing anonymous types with Lambdas to the “Error” severity level:

img

By doing this, when running the “Inspect Code” function of IDEA, you can see this issue among the errors at the “Error” severity level, which will attract more attention and help us develop the habit of using Lambda expressions:

img

Third, if you don’t know how to convert anonymous classes to Lambda expressions, you can use the refactoring feature of your IDE:

img

Conversely, if you find it difficult to understand Lambda expressions and Stream APIs while studying the examples in the course, you can also use your IDE to convert Java 8 syntax to traditional loop-based syntax:

img

Or replace Lambda expressions with anonymous classes:

img

Lambda Expressions #

The original intention of lambda expressions is to simplify the syntax of anonymous classes (although lambda expressions are not actually syntax sugar for anonymous classes), and to make Java move towards functional programming. For anonymous classes, although there is no class name, a method definition still needs to be provided. Here is an example that uses an anonymous class and a lambda expression to create a thread that prints a string:

// Anonymous Class
new Thread(new Runnable(){
    @Override
    public void run(){
        System.out.println("hello1");
    }
}).start();

// Lambda Expression
new Thread(() -> System.out.println("hello2")).start();

So, how do lambda expressions match Java’s type system?

The answer is functional interfaces.

Functional interfaces are interfaces with a single abstract method, described using the @FunctionalInterface annotation, and can be implicitly converted to lambda expressions. Using lambda expressions to implement functional interfaces does not require providing a class name and method definition. With just one line of code, a functional interface instance can be provided as a first-class citizen in the program, and can be passed as a parameter, just like normal data, rather than as a fixed method in a fixed class.

So, what exactly does a functional interface look like? The java.util.function package defines various functional interfaces. For example, the Supplier interface, which provides data, has only one get abstract method, without any input parameters and with one return value:

@FunctionalInterface
public interface Supplier<T> {
    /**
     * Gets a result.
     *
     * @return a result
     */
    T get();
}

We can use lambda expressions or method references to obtain instances of the Supplier interface:

// Using a lambda expression to provide an implementation of the Supplier interface that returns the string "OK"
Supplier<String> stringSupplier = () -> "OK";

// Using a method reference to provide an implementation of the Supplier interface that returns an empty string
Supplier<String> supplier = String::new;

Isn’t that convenient? To help you understand functional interfaces and their usage, let me give you a few more examples of using lambda expressions or method references to construct functions:

  • The Predicate interface takes an input parameter and returns a boolean value. We can use the and method to combine two predicate conditions to determine if a value is greater than 0 and even:
Predicate<Integer> positiveNumber = i -> i > 0;
Predicate<Integer> evenNumber = i -> i % 2 == 0;
assertTrue(positiveNumber.and(evenNumber).test(2));
  • The Consumer interface consumes data. We can use the andThen method to combine two consumers and output two lines of “abcdefg”:
Consumer<String> println = System.out::println;
println.andThen(println).accept("abcdefg");
  • The Function interface takes an input and calculates an output. We can convert a string to uppercase and then combine it with another function that duplicates the string:
Function<String, String> upperCase = String::toUpperCase;
Function<String, String> duplicate = s -> s.concat(s);
assertThat(upperCase.andThen(duplicate).apply("test"), is("TESTTEST"));
  • The Supplier interface provides data. Here, we implement a supplier to obtain a random number:
Supplier<Integer> random = () -> ThreadLocalRandom.current().nextInt();
System.out.println(random.get());
  • The BinaryOperator interface takes two input parameters of the same type and returns a result of the same type. Here, we use a method reference to get an addition operation for integers, and define a subtraction operation using a lambda expression, and then call them sequentially:
BinaryOperator<Integer> add = Integer::sum;
BinaryOperator<Integer> subtraction = (a, b) -> a - b;
assertThat(subtraction.apply(add.apply(1, 2), 3), is(0));

Predicate, Function, and other functional interfaces also implement several default methods using the default keyword. This way, they can satisfy the requirement of having only one abstract method for functional interfaces, while providing additional functionality to the interface:

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);

    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }
}

Clearly, lambda expressions give us more possibilities for code reuse: we can abstract out the changing parts of a large block of logic into a functional interface, and have the function implementation provided by an external method, allowing for the reuse of the overall logic in the method.

However, it is important to note that before defining custom functional interfaces, we can first check if the 43 standard functional interfaces in the java.util.function package meet our needs. We should strive to reuse these standard interfaces as much as possible, as using familiar standard interfaces can improve the readability of our code.

Simplifying Code with Java 8 #

In this section, I will guide you through several examples to experience the three important aspects of simplifying code with Java 8:

  1. Simplifying collection operations using Stream.
  2. Simplifying null checking logic using Optional.
  3. Enhancements to various classes with Lambda and Stream in JDK 8.

Simplifying Collection Operations Using Stream #

Lambda expressions allow us to define methods using concise code, giving us more possibilities for code reuse. With this feature, we can abstract collection operations such as projection, transformation, and filtering into generic interfaces, and then pass in their specific implementations using Lambda expressions. This is what Stream operations do.

Let’s look at a specific example. Here is a piece of code with about 20 lines that achieves the following logic:

  1. Converts a list of integers into a list of Point2D.
  2. Filters out objects from the Point2D list whose Y-axis is greater than 1.
  3. Calculates the distance from each Point2D point to the origin.
  4. Accumulates all calculated distances and calculates the average distance.
private static double calc(List<Integer> ints) {

    // Temporary intermediate list
    List<Point2D> point2DList = new ArrayList<>();

    for (Integer i : ints) {
        point2DList.add(new Point2D.Double((double) i % 3, (double) i / 3));
    }

    // Temporary variables, purely for obtaining the intermediate variables needed for the final result
    double total = 0;
    int count = 0;

    for (Point2D point2D : point2DList) {
        // Filter
        if (point2D.getY() > 1) {
            // Calculate distance
            double distance = point2D.distance(0, 0);
            total += distance;
            count++;
        }
    }

    // Note that count may be 0
    return count > 0 ? total / count : 0;

}

Now, we can simplify this code using Stream and Lambda expressions. With just one line of code, we can achieve the same logic, but more importantly, the code becomes more readable, and you can have a general understanding of what it is doing just from the method names. For example:

  • The map method takes a Function parameter, which can be used to perform object conversion.
  • The filter method takes a Predicate parameter, which implements the boolean check of objects and only keeps data that returns true.
  • The mapToDouble method is used to convert objects to double.
  • The average method returns an OptionalDouble, which represents a double that may or may not be present.

The following code, which is the third line, achieves all the work of the previous method:

List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
double average = calc(ints);
double streamResult = ints.stream()
        .map(i -> new Point2D.Double((double) i % 3, (double) i / 3))
        .filter(point -> point.getY() > 1)
        .mapToDouble(point -> point.distance(0, 0))
        .average()
        .orElse(0);

// Comparing the readability of doing everything in one line
assertThat(average, is(streamResult));

At this point, you might be wondering what OptionalDouble is all about.

Introduction to Optional and Nullable Types #

In fact, OptionalDouble, OptionalInt, OptionalLong, etc. are objects that cater to nullable types of primitive types. In addition, Java 8 also defined the Optional class for reference types. Using Optional not only avoids null pointer issues when cascading method calls with Stream, but more importantly, it provides some useful methods to help us avoid null checking logic.

Here are some examples that demonstrate how to use Optional to avoid null pointers and simplify if-else null checking logic using its fluent API:

@Test(expected = IllegalArgumentException.class)
public void optional() {

    // Use the `get` method to retrieve the actual value from an Optional
    assertThat(Optional.of(1).get(), is(1));

    // Initialize `null` using `ofNullable` and use `orElse` to return a default value when there is no data in the Optional
    assertThat(Optional.ofNullable(null).orElse("A"), is("A"));

    // `OptionalDouble` is an Optional object for the primitive type `double`. Use `isPresent` to check whether there is data
    assertFalse(OptionalDouble.empty().isPresent());

    // Use `map` to perform cascade conversions on Optional objects, and null pointers will not occur. After the conversion, it is still an Optional
    assertThat(Optional.of(1).map(Math::incrementExact).get(), is(2));

    // Use `filter` to filter data in Optional and get an Optional. Then, use `orElse` to provide a default value
    assertThat(Optional.of(1).filter(integer -> integer % 2 == 0).orElse(null), is(nullValue()));

    // Use `orElseThrow` to throw an exception when there is no data
    Optional.empty().orElseThrow(IllegalArgumentException::new);
}

I have summarized the common methods of the Optional class into an image for your reference:

img

Enhancements to Java 8 Classes for Functional APIs #

In addition to Stream, many classes in Java 8 also implement functional capabilities.

For example, to implement a caching operation using HashMap, before Java 8, we might write a getProductAndCache method like this: first, check if the cache contains the key; if not, search the database to get the value; finally, add the data to the cache.

private Map<Long, Product> cache = new ConcurrentHashMap<>();

private Product getProductAndCache(Long id) {
    Product product = null;

    // If the key exists, return the value
    if (cache.containsKey(id)) {
        product = cache.get(id);

} else {

// If the product doesn’t exist, get the value

// Need to iterate through the data source to get the product

for (Product p : Product.getData()) {

if (p.getId().equals(id)) {

product = p;

break;

}

}

// Add to the ConcurrentHashMap

if (product != null)

cache.put(id, product);

}

return product;

}

@Test

public void notcoolCache() {

getProductAndCache(1L);

getProductAndCache(100L);

System.out.println(cache);

assertThat(cache.size(), is(1));

assertTrue(cache.containsKey(1L));

}

In Java 8, we can use the computeIfAbsent method of ConcurrentHashMap to achieve this cumbersome operation in just one line of code:

private Product getProductAndCacheCool(Long id) {

return cache.computeIfAbsent(id, i -> // provide a function to represent the process of getting the value based on the key when the key doesn’t exist

Product.getData().stream() .filter(p -> p.getId().equals(i)) // filter .findFirst() // get the first one, resulting in an Optional .orElse(null)); // if no Product is found, use null

}

@Test

public void coolCache() {

getProductAndCacheCool(1L);

getProductAndCacheCool(100L);

System.out.println(cache);

assertThat(cache.size(), is(1));

assertTrue(cache.containsKey(1L));

}

The computeIfAbsent method is equivalent to the following logic:

if (map.get(key) == null) {

V newValue = mappingFunction.apply(key);

if (newValue != null)

map.put(key, newValue);

}

For example, using Files.walk to return a stream of Paths, we can achieve recursive search and grep with just two lines of code. The logic is as follows: recursively search folders, find all .java files; then read each line of the file, match the public class keyword with a regular expression; finally, output the file name and the matched line.

@Test

public void filesExample() throws IOException {

// recursively traverse folders with infinite depth

try (Stream pathStream = Files.walk(Paths.get("."))) {

pathStream.filter(Files::isRegularFile) // only search regular files .filter(FileSystems.getDefault().getPathMatcher(“glob:**/*.java”)::matches) // search for java source code files .flatMap(ThrowingFunction.unchecked(path ->

Files.readAllLines(path).stream() // read the file content and convert it to a Stream .filter(line -> Pattern.compile(“public class”).matcher(line).find()) // filter lines that contain ‘public class’ using regex .map(line -> path.getFileName() + " » " + line))) // convert the file content to file name + line .forEach(System.out::println); // print all lines

}

}

The output is as follows:

Here’s a small tip I’d like to share with you. Because the Files.readAllLines method can throw a checked exception (IOException), I used my own functional interface, ThrowingFunction, to wrap this method and convert the checked exception to a runtime exception, making the code clearer:

@FunctionalInterface public interface ThrowingFunction<T, R, E extends Throwable> { static <T, R, E extends Throwable> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) { return t -> { try { return f.apply(t); } catch (Throwable e) { throw new RuntimeException(e); } }; }

R apply(T t) throws E; }

If you were to implement similar logic in Java 7, it would probably take dozens of lines of code. You can give it a try.

Parallel Stream #

The Stream operations we have seen so far are all sequential Streams, where the operations are executed in a single thread. However, Java 8 also provides the functionality of parallel Streams. By using the parallel method, we can easily convert a Stream into a parallel operation and submit it to a thread pool for processing.

For example, the following code uses a thread pool to process numbers from 1 to 100 in parallel:

IntStream.rangeClosed(1,100).parallel().forEach(i->{

    System.out.println(LocalDateTime.now() + " : " + i);

    try {

        Thread.sleep(1000);

    } catch (InterruptedException e) { }

});

Parallel Streams do not guarantee the order of execution, and because each operation takes 1 second to process, you can see that on an 8-core machine, the numbers are outputted every 1 second in groups of 8:

img

In this course, there are many examples where we use threadCount threads to perform taskCount operations in order to demonstrate multi-threading issues or performance in concurrent scenarios. In addition to using parallel Streams, we sometimes use thread pools or directly use threads to perform similar operations. To facilitate your comparison of various implementations, here I will provide you with five ways to implement this type of operation.

To test these five implementation methods, we will design a scenario: use 20 threads (threadCount) to perform a total of 10,000 operations (taskCount) in parallel. Since each individual task takes 10 milliseconds to execute in a single thread, which means we have a throughput of 100 operations per second, the throughput of 20 threads would be 2000, and it would take at least 5 seconds to complete 10,000 operations.

private void increment(AtomicInteger atomicInteger) {

    atomicInteger.incrementAndGet();

    try {

        TimeUnit.MILLISECONDS.sleep(10);

    } catch (InterruptedException e) {

        e.printStackTrace();

    }

}

Now let’s test these five methods to see if they can all utilize more threads to execute operations in parallel.

The first method is using threads. We directly divide the tasks evenly among the threads and allocate them to different threads for execution. We use CountDownLatch to block the main thread until all threads have completed their operations. In this method, we need to divide the tasks ourselves:

private int thread(int taskCount, int threadCount) throws InterruptedException {

    // Counter for total operation count

    AtomicInteger atomicInteger = new AtomicInteger();

    // Use CountDownLatch to wait for all threads to complete

    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    // Use IntStream to directly convert numbers to threads

    IntStream.rangeClosed(1, threadCount).mapToObj(i -> new Thread(() -> {

        // Manually divide the taskCount into threadCount portions, and each portion is executed by one thread

        IntStream.rangeClosed(1, taskCount / threadCount).forEach(j -> increment(atomicInteger));

        // After each thread completes processing its own portion of data, count down once

        countDownLatch.countDown();

    })).forEach(Thread::start);

    // Wait for all threads to complete

    countDownLatch.await();

    // Query the current value of the counter

    return atomicInteger.get();

}

The second method is to use Executors.newFixedThreadPool to obtain a thread pool with a fixed number of threads. We use execute to submit all tasks to the thread pool for execution, and finally, close the thread pool and wait for all tasks to complete:

private int threadpool(int taskCount, int threadCount) throws InterruptedException {

    // Counter for total operation count

    AtomicInteger atomicInteger = new AtomicInteger();

    // Initialize a thread pool with a number of threads equal to threadCount

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

    // Submit all tasks to the thread pool for processing

    IntStream.rangeClosed(1, taskCount).forEach(i -> executorService.execute(() -> increment(atomicInteger)));

    // Submit a request to shut down the thread pool and wait for all previous tasks to complete

    executorService.shutdown();

    executorService.awaitTermination(1, TimeUnit.HOURS);
// Query the current value of the counter
return atomicInteger.get();

The third way is to use ForkJoinPool instead of a regular thread pool to execute tasks.

The difference between ForkJoinPool and the traditional ThreadPoolExecutor is that the former has n independent queues for n parallelism, while the latter has a shared queue. If there are a large number of tasks that have short execution time, the single queue of ThreadPoolExecutor may become a bottleneck. In this case, using ForkJoinPool will provide better performance.

Therefore, ForkJoinPool is more suitable for scenarios where large tasks are divided into many small tasks and executed in parallel, while ThreadPoolExecutor is suitable for scenarios where many independent tasks are executed concurrently.

Here, we first customize a ForkJoinPool with a specified parallelism of threadCount, and then execute operations in parallel using this ForkJoinPool:

private int forkjoin(int taskCount, int threadCount) throws InterruptedException {

    // Counter for total operation count
    AtomicInteger atomicInteger = new AtomicInteger();

    // Customized ForkJoinPool with parallelism of `threadCount`
    ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);

    // Submit all tasks to the thread pool for processing
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)));

    // Submit request to shut down the thread pool and wait for all previous tasks to complete
    forkJoinPool.shutdown();

    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);

    // Query the current value of the counter
    return atomicInteger.get();

}

The fourth way is to directly use parallel streams. Parallel streams use the common ForkJoinPool, namely ForkJoinPool.commonPool().

The default parallelism of the common ForkJoinPool is CPU core count - 1, because allocating more threads to a CPU-bound task than the number of CPU cores is meaningless. As parallel streams also use the main thread to execute tasks, which also occupies one CPU core, the parallelism of the common ForkJoinPool remains full even if it is set to -1.

In this example, we forcibly specify (increase) the parallelism by configuring the system property, but because the common ForkJoinPool is used, there may be interference. You can review the problems caused by mixing thread pools discussed in the third lesson:

private int stream(int taskCount, int threadCount) {

    // Set the parallelism of the common ForkJoinPool
    System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(threadCount));

    // Counter for total operation count
    AtomicInteger atomicInteger = new AtomicInteger();

    // As we have set the parallelism of the common ForkJoinPool, we can directly use `parallel()` to submit tasks
    IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger));

    // Query the current value of the counter
    return atomicInteger.get();

}

The fifth way is to use CompletableFuture. The CompletableFuture.runAsync method allows specifying a thread pool, which is usually used when using CompletableFuture:

private int completableFuture(int taskCount, int threadCount) throws InterruptedException, ExecutionException {

    // Counter for total operation count
    AtomicInteger atomicInteger = new AtomicInteger();

    // Customized ForkJoinPool with parallelism of `threadCount`
    ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);

    // Use `CompletableFuture.runAsync` to execute the task asynchronously using the specified thread pool
    CompletableFuture.runAsync(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)), forkJoinPool).get();

    // Query the current value of the counter
    return atomicInteger.get();

}

All five methods above can achieve similar results:

img

As shown in the image, the execution time for 10000 tasks using these five methods is between 5.4 seconds and 6 seconds. These results only demonstrate that the setting of parallelism is effective, and do not compare performance.

If your program has strict performance requirements, it is recommended to determine the appropriate mode based on performance testing for each scenario. Generally, using a thread pool (the second method) and directly using parallel streams (the fourth method) are more commonly used in business code. However, it is important to note that we usually reuse thread pools instead of declaring new thread pools in the business logic as shown in the example, and closing the thread pool after the operations are completed.

Also, note that in the example above, the stream method must be executed before the forkjoin method in order for the modification of the default parallelism of the common ForkJoinPool to take effect.

This is because the ForkJoinPool class initializes the common thread pool in a static code block, which is executed when the class is loaded. If the ForkJoinPool is used first in the forkjoin method, even if the system property is set in the stream method, it will not take effect. Therefore, my recommendation is to set the default parallelism of the ForkJoinPool common thread pool when the application starts.

Key Review #

Today, I briefly introduced to you several important features in Java 8, including Lambda expressions, Stream operations, Optional objects, and parallel stream operations. These features can help us write code that is simple, understandable, and more readable. Especially with the use of Stream’s chained methods, we can accomplish the work that previously required dozens of lines of code in just one line.

Because the Stream API has a wide range of methods and usage patterns, I will go into more detail about some of the usage details in the next lecture.

I have put the code we used today on GitHub, and you can click on this link to view it.

Reflection and Discussion #

Check whether there is any usage of anonymous classes in the code, and see if it is possible to use Lambda expressions and Streams to reimplement the data filtering, transformation, and aggregation through List iteration.

For the example of parallel processing from 1 to 100 using forEach, what do you think would happen if we replace it with forEachOrdered?

Regarding Java 8, do you have any learned experiences to share? I am Zhu Ye, you are welcome to leave me a message in the comments section to share your thoughts. Feel free to share this article with your friends or colleagues and discuss together.