Collectors joining in Java 8

Introduction

The Collectors joining method in Java 8 will return a collector using which we can concatenate the stream elements into a single string. We will learn about the Collectors joining method in Java 8 in this post.

Collectors joining methods in Java 8

Collectors.joining will return a new Collector which will concatenate the stream elements (which must be a CharSequence) separated by a delimiterprefix and suffix in encounter order.

There are three static overloaded Collectors.joining methods offered by the Collector class:

With no delimiter, prefix and suffix

Method signature:
public static Collector<CharSequence, ?, String> joining()

The Collector returned by this method will join the stream elements (CharSequences) and returns the concatenated string. It doesn’t use any delimiter, prefix or suffix.

With delimiter only

Method signature: 
public static Collector<CharSequence, ?, String> joining(CharSequence delimiter)

We pass a delimiter to get back a Collector, which concatenates the elements separated by the specified delimiter.

With delimiter, prefix and suffix

Method signature: 
public static Collector<CharSequence, ?, String> joining( CharSequence delimiter, 
CharSequence prefix, CharSequence suffix)

To this joining method we pass a delimiter, prefix and a suffix. It concatenates the stream elements using the delimiter (just like the previous method). It adds the prefix to the beginning of the concatenated result and suffix to the end of the result.

Collectors joining with delimiter, prefix and suffix

We create a list of strings using the convenience factory method of from the List class. Then we create a stream out of the list and pass the collector returned from Collectors#joining to the collect method.

We pass comma as the delimiter and [ as prefix and ] as the suffix.

List<String> list = List.of("Java", "Java-8", "Java Streams", "Concurrency");
String appendedString = list.stream()
        .collect(Collectors.joining(",", "[", "]"));
System.out.println(appendedString); //[Java,Java-8,Java Streams,Concurrency]

It joined each stream element using comma and added the configured prefix and suffix to the result.

With empty and null strings

Let us look at the case when the stream has empty strings and nulls. Empty string is treated just like any other string.

From the output, we can see there are two successive commas.

List<String> list = List.of("Java", "Java-8", "", "Concurrency");
System.out.println(list.stream()
        .collect(Collectors.joining(",", "[", "]"))); //[Java,Java-8,,Concurrency]

Since List.of doesn’t allow passing a null, I have used Arrays.asList for this example.

List<String> list = Arrays.asList("Java", "Java-8", null, "Concurrency");
System.out.println(list.stream()
        .collect(Collectors.joining(",", "[", "]"))); //[Java,Java-8,null,Concurrency]

When it sees a null, it adds the string “null” to the result string.

Collectors.joining empty stream

With an empty stream, there are no elements to concatenate. Thus the result will just have the prefix and the suffix.

System.out.println(List.<String>of().stream()
        .collect(Collectors.joining(",", "[", "]"))); // []

Joining with delimiter only

Let us now use the other Collectors.joining method by passing only a delimiter (no prefix/suffix).

Joining with comma

Let us call the one argument version of the joining method passing the delimiter as comma.

It processes the elements in the stream, joins each string with a comma.

List<String> list = Arrays.asList("Java", "Java-8", "Java Streams", "Concurrency");
System.out.println(list.stream()
        .collect(Collectors.joining(","))); //Java,Java-8,Java Streams,Concurrency

Some IDEs might suggest to convert the above code to use String#join method as:

System.out.println(String.join(",", list)); //Java,Java-8,Java Streams,Concurrency

When stream has empty strings and null

When there are empty strings or null in the stream, the behaviour is same as seen before; empty string treated as yet another string and null will be converted to string “null”.

List<String> list = List.of("Java", "Java-8", "", "Concurrency");
System.out.println(list.stream()
        .collect(Collectors.joining(","))); //Java,Java-8,,Concurrency

List<String> list = Arrays.asList("Java", "Java-8", null, "Concurrency");
System.out.println(list.stream()
        .collect(Collectors.joining(","))); //Java,Java-8,null,Concurrency

Empty stream

When the stream is empty, applying Collectors.joining with a delimiter will result in an empty string.

System.out.println(List.<String>of().stream()
        .collect(Collectors.joining(","))); //""

Collectors.joining new line

If we had used a new line as the delimiter, the output would have one string in each line.

List<String> list = Arrays.asList("Java", "Java-8", "Java Streams", "Concurrency");
System.out.println(list.stream()
        .collect(Collectors.joining("\n")));

Outputs,

Java
Java-8
Java Streams
Concurrency

Collectors Joining with no delimiter, prefix or suffix

Finally, we will use the no-argument joining method from Collectors. It just joins each element and returns as a string (no delimiter, no prefix/suffix). 

I have added all scenarios in one section itself.

List<String> list = Arrays.asList("ab", "cd", "ef");
System.out.println(list.stream()
        .collect(Collectors.joining()));
System.out.println(String.join(",", list)); //abcdef

With a null in the stream, the output string will have “null” in it.

//empty strings and null
List<String> list = List.of("ab", "cd", "", "ef");
System.out.println(list.stream()
        .collect(Collectors.joining())); //abcdef
List<String> list = Arrays.asList("ab", "cd", null, "ef");
System.out.println(list.stream()
        .collect(Collectors.joining())); //abcdnullef

Finally, empty stream will result in empty string.

//empty stream
System.out.println(List.<String>of().stream()
        .collect(Collectors.joining())); //""

Using Collectors.joining with a source with no encounter order

So far we have used Collectors#joining on a stream created out of a List. A List has an encounter order and hence the elements will be processed in the original order as in the list by the stream pipeline stages (unless we change it with an operation that returns an unordered stream).

Let us use joining on a stream created from a Set which has no encounter order.

Set<String> set = Set.of("Java", "Java-8", "Java Streams", "Concurrency");
System.out.println(set.stream()
        .collect(Collectors.joining(",", "[", "]")));

If you run the above code a few times, you can see that the output will keep changing. You could get result like [Java-8,Concurrency,Java,Java Streams] or [Concurrency,Java,Java Streams,Java-8] or in some other order. This is because the stream created from the source/object returned by Set.of has no encounter order and it can return elements in any order.

Collectors.joining on a ordered, parallel stream

If you make an ordered stream parallel and use Collectors.joining in it, it will suffer from performance problem. Since the stream is ordered, the joining method is constrained to concatenate the elements as per the original encounter order. Hence, there will be internal buffering overhead to join them in the encounter order.

List<String> list = List.of("Java", "Java-8", "Java Streams", "Concurrency");
//should affect performance
System.out.println(list.stream()
        .parallel()
        .collect(Collectors.joining(",", "[", "]"))); //[Java,Java-8,Java Streams,Concurrency]

Here, you could

  1. Remove the parallel() call which will make the stream sequential or 
  2. Make the stream unordered by adding unordered() call before collect.

Collectors.joining on a stream of objects

As mentioned before, the joining collector will work with CharSequence only. Hence, when we have a stream of some object, we have to map it to a CharSequence before the Collectors.joining call.

As a small example, consider the below Student class.

public class Student {
    private String name;
    private int age;

    private Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

Create a stream from the list of students and map each Student object to their name using the Stream#map method. Finally, we can collect using Collectors#joining.

List<Student> students = List.of(
        new Student("Paul", 19),
        new Student("John", 21),
        new Student("Monty", 20)
);

System.out.println(students.stream()
        .map(Student::getName)
        .collect(Collectors.joining(",", "{", "}"))); //{Paul,John,Monty}

Alternatively, we could use Collectors.joining as a downstream collector of Collectors.mapping as well. But the earlier approach is more readable.

System.out.println(students.stream()
        .collect(Collectors.mapping(Student::getName,
                Collectors.joining(",", "{", "}")))); //{Paul,John,Monty}

When the student name is null, both the above code (using Stream#map and Collectors#mapping) will work in the same way.

List<Student> students = List.of(
        new Student("Paul", 19),
        new Student("John", 21),
        new Student(null, 20)
);

Using either of the approaches will produce {Paul,John,null}.

However, if there is a null in the stream, both approaches will throw a NullPointerException. We have to handle null by filtering nulls before map using a filter for the first approach. For the second, we can use Collectors.filtering (first approach is preferred as it is more readable).

List<Student> students = Arrays.asList(
        new Student("Paul", 19),
        new Student("John", 21),
        new Student("Monty", 20),
        null

);
System.out.println(students.stream()
        .filter(Objects::nonNull)
        .map(Student::getName)
        .collect(Collectors.joining(",", "{", "}"))); //{Paul,John,Monty}

System.out.println(students.stream()
        .collect(Collectors.filtering(Objects::nonNull,
                Collectors.mapping(Student::getName,
                        Collectors.joining(",", "{", "}"))))); //{Paul,John,Monty}

Internal implementation details

The Collectors.joining method with no arguments uses a StringBuilder to join the strings, whereas the other two joining methods use the StringJoiner that I wrote about in the last post. It uses:

  • StringJoiner#add as the accumulator
  • StringJoiner#merge as the combiner
  • StringJoiner#toString as the finisher

An accumulator here concatenates stream elements with the previous result (starts with an empty string) iteratively building the final result. The combiner is used only for parallel streams where it takes two partial result strings from two threads and merges it. The finisher returns the final result from the internal StringJoiner.

Conclusion

In this post, I covered the Collectors.joining method in Java 8. We learnt the three overloaded joining methods from the Collectors class with examples. Collectors have many other useful methods which we can use in the collect step of a stream. Check them out here.

Leave a Reply