CHAP08 - Modern-Java-in-Action/Online-Study GitHub Wiki

컬렉션 API 개선

  • 작은 리스트, 집합, 맵을 쉽게 만들 수 있도록 개선된 컬렉션 팩토리
  • 리스트와 집합에서 요소를 삭제하거나 바꾸는 관용 패턴 적용 방법
  • 맵 작업과 관련해 추가된 새로운 편리 기능

미리 알아두면 좋은 것들

  • 팩토리 메서드 : 객체 생성 코드를 별도 클래스/메서드로 분리하여 객체 생성의 변화에 대비하는 데 유용하다.

  • 객체를 final 키워드로 선언해도, 내부 데이터는 바꿀 수 있다.

    final Person p = new Person("Bob", 30);
    //p = new Person("John", 25);
    p.name = "John";
    p.age = 25;
    
    private static class Person{
    	public String name;
    	public int age;
    
    	Person(String name, int age){
    	this.name = name;
    	this.age = age;
    	}
    }

개요

  • 자바 9는 적은 원소를 포함하며 바꿀 수 없는 리스트, 집합, 맵을 쉽게 만들 수 있도록 List.of, Set.of, Map.of, Map.ofEntries 등 컬렉션 팩토리를 지원한다.
  • List 인터페이스에서 removeIf, replaceAll, sort 세 가지 디폴트 메서드를 지원한다.
  • Set 인터페이스는 removeIf 디폴트 메서드를 지원한다.
  • Map 인터페이스는 자주 사용하는 패턴과 버그를 방지할 수 있도록 다양한 디폴트 메서드를 지원한다.
  • ConcurrentHashMap은 Map에서 상속받은 새 디폴트 메서드를 지원함과 동시에 스레드 안전성도 제공한다.
구분 메서드 설명
공통 List.of, Set.of, Map.of 바꿀 수 없는 리스트/집합/맵 객체를 생성한다.
리스트/집합 removeIf 프리디케이트를 만족하는 요소를 제거
replaceAll UnaryOperator 함수로 요소를 바꿈
sort 정렬
forEach Biconsumer를 넘겨 키와 값을 반복하여 처리
sorted 정렬
getOrDefault 찾으려는 키가 존재하지 않을때 반환할 기본값을 지정
computeIfAbsent 제공된 키에 해당하는 값이 없으면, 키로 새 값을 계산하고 맵에 추가
computeIfPresent 제공된 키에 해당하는 값이 있으면, 키를 이용해 새 값을 계산하고 변경
compute 제공된 키로 새 값을 계산하고 저장
remove 키/값을 인자로 받아 해당되는 데이터를 삭제
replaceAll BiFunction을 적용한 결과로 각 항목의 값을 교체
replace 키가 존재하면 맵의 값을 바꿈
merge 키, 값, 키 중복시 처리할 BiFunctino을 인자로 넘긺
ConcurrentHashMap forEach 키/값 쌍에 주어진 액션을 실행
reduce 모든 키/값 쌍을 제공된 리듀스 함수를 이용해 합침
search null 아닌 값을 반환할 때까지 키/값에 함수를 적용
mappingCount long을 반환(기존 size는 int)
keySet 키값을 집합으로 변환, 집합/맵은 서로 변경사항이 동기화됨

예제 데이터 만들기

maven 프로젝트에서 pom.xml에 아래와 같이 의존성을 추가한다.

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.17.4</version>
</dependency>

Faker 객체를 생성해 임의의 데이터를 생성할 수 있다.

Faker faker = new Faker();
documents = new HashMap<>();

for(int i=0; i<10; i++){
    documents.put(faker.dune().character(), faker.dune().planet());
}

documents.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEachOrdered(System.out::println);
documents.forEach((name, address)->System.out.println(name+" : "+address));
System.out.println(documents.getOrDefault("Chani", "NOT_FOUND_CHANI"));

지원하는 데이터셋 : 주소, 이름, 항공기, 코인, 고양이, 색 등 82종

DiUS/java-faker: Brings the popular ruby faker gem to Java (github.com)

java faker 로 테스트 데이타 만들기 (lesstif.com)

](https://www.lesstif.com/java/java-faker-48988763.html)

HashMap<String, String> documents = new HashMap<>();
Faker faker = new Faker();
for(int i=0; i<100000; i++){
    documents.put(faker.name().fullName(), faker.address().fullAddress());
}

//documents.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEachOrdered(System.out::println);
//documents.forEach((name, address)->System.out.println(name+" : "+address));
//System.out.println(documents.getOrDefault("Chani", "NOT_FOUND"));
//documents.entrySet().stream().parallel().sorted(Map.Entry.comparingByKey()).limit(10).forEachOrdered(System.out::println);

System.out.println("# filter startWith A, limit 10");
documents.entrySet().stream().filter(e -> e.getKey().startsWith("A")).limit(10).forEach(System.out::println);

System.out.println("\n# distinct, limit 10");
documents.entrySet().stream().distinct().limit(10).forEach(System.out::println);

System.out.println("\n# map address -> length, limit 10");
documents.entrySet().stream().map(e -> e.getValue().length()).limit(10).forEach(System.out::println);

System.out.println("\n# collect, joining string");
System.out.println(documents.keySet().stream().limit(10).collect(joining(" ")));

System.out.println("\n# collect, reducing for joining string");
System.out.println(documents.keySet().stream().limit(10).collect(reducing("", (o, v)->o+","+v)));

System.out.println("\n# reduce for joining string");
System.out.println(documents.keySet().stream().limit(10).reduce("", (o, v)->o+","+v));

System.out.println("\n# replaceAll name toUpperCase, limit 10");
documents.replaceAll((k,v)->k.toUpperCase());
documents.entrySet().stream().limit(10).forEach(System.out::println);

8.1 컬렉션 팩토리

  • Arrays.asList로 만든 리스트는 데이터가 final 배열에 담겨 있다.
  • 데이터를 추가할 수 없고, 내부 데이터는 바꿀 수 있다.

기존 자바에서 아래 코드는 UnsupportedException을 뱉는다.

List<String> friends2 = Arrays.asList("Rael", "Oli");
friends2.set(0, "Rich");
friends2.add("Thiba"); //예외발생

왜냐하면 Arrays.asList는 자체 정의한 private ArrayList를 반환하기 때문이다. 고정크기 final 배열을 만들어 초기화한 후에는 새 배열로 바꾸지 못하게 했고, 내부 데이터만 변경할 수 있다. 그래서 AbstractList 추상클래스는 set, add 모두 UnsupportedException 예외를 던지는데, 그중 set 메서드만 재정의해두었다.

private static class ArrayList<E> extends AbstractList<E>
        implements RandomAccess, java.io.Serializable
    {
		private final E[] a;

		@Override
        public E set(int index, E element) {
            E oldValue = a[index];
            a[index] = element;
            return oldValue;
        } 
    }

8.1.1 리스트 팩토리

  • List.of로 만든 리스트도 데이터가 final 배열에 담겨 있다.
  • 데이터를 추가/수정/삭제하는 모든 메서드가 예외를 던진다.
  • 생성자에서 10개 이하 인자는 고정크기 배열로 데이터를 받는다. 인자 개수가 그 이상인 경우 배열크기를 늘려가며 데이터를 받고, 가비지 컬렉션 비용이 추가로 들어간다.

다음 코드에서도 add/set 메서드 호출시 UnsupportedException을 뱉는다.

List<String> friends = List.of("Rael", "Oli", "Thiba");
friends.add("Taey"); //예외발생
friends.set(0, "Taey"); //예외발생

List.ofImmutableCollections.ListN<>()를 반환한다. ListN은 AbstractImmutableList을 상속하는 정적 클래스이고, AbstractImmutableList는 아래와 같이 final 배열로 데이터를 담고, 리스트를 수정하는 모든 메서드에서 예외를 던지도록 설계되어 있다.

static final class ListN<E> extends AbstractImmutableList<E>
            implements Serializable {

        static final List<?> EMPTY_LIST = new ListN<>();
    
        @Stable
        private final E[] elements;
    }
    static abstract class AbstractImmutableList<E> extends AbstractImmutableCollection<E>
            implements List<E>, RandomAccess {

        // all mutating methods throw UnsupportedOperationException
        // uoe()는 UnsupportedException을 던지는 메서드
        @Override public void    add(int index, E element) { throw uoe(); }
        @Override public boolean addAll(int index, Collection<? extends E> c) { throw uoe(); }
        @Override public E       remove(int index) { throw uoe(); }
        @Override public void    replaceAll(UnaryOperator<E> operator) { throw uoe(); }
        @Override public E       set(int index, E element) { throw uoe(); }
        @Override public void    sort(Comparator<? super E> c) { throw uoe(); }
    }

8.1.2 집합 팩토리

  • Set.of로 바꿀 수 없는 집합을 만들 수 있다.
  • 중복된 데이터를 넘기면 예외를 던진다.
Set<String> friends = Set.of("Rael", "Oli", "Thiba");
Set<String> friends2 = Set.of("Rael", "Oli", "Thiba", "Rael"); //예외발생

//Stream을 사용해 유일한 값을 추출할 수는 있지만, 고정크기로 가벼운 집합을 만들려는 의도에서 한참 벗어나 버린다.
Set<String> friends3 = List.of("Rael", "Oli", "Thiba", "Rael").stream().distinct().collect(toSet());

8.1.3 맵 팩토리

  • Map.of로 바꿀 수 없는 맵을 만들 수 있다.
  • entry는 Map.Entry 객체를 만드는 팩토리 메서드이며, 내부 데이터를 변경할 수 없다.
Map<String, Integer> ageOfFriends = Map.of("Rael", 30, "Oli", 25, "Thiba", 26);
Map<String, Integer> ageOfFriends2 = Map.ofEntries(
	entry("Rael", 30),
	entry("Oli", 25),
	entry("Thiba", 26)
);

8-1 Quiz

다음 코드의 실행결과는?

List<String> actors = List.of("Keanu", "Jessica");
actors.set(0, "Brad");
System.out.println(actors);

8.2 리스트와 집합 처리 / map.entrySet()

8.2.1 removeIf

  • removeIf : 프리디케이트를 만족하는 요소를 제거, List나 Set을 구현하거나 그 구현을 상속받은 모든 클래스에서 이용.

removeIf를 사용하지 않을 때

컬렉션에서 특정 객체를 골라서 삭제하려면? for-each는 내부적으로 Iterator를 사용한다. 리스트를 순회하는 데 Iterator를 사용하고, 리스트에서 데이터를 삭제하는 데 컬렉션으로 접근하면 두 객체간 동기화가 이뤄지지 않아 오류가 발생한다. 그래서 아래 코드는 ConcurrentModificationException을 던진다.

TestData td = new TestData();
for(Person p : td.people){
    if(p.age > 50) td.people.remove(p);
}

위 코드는 아래와 같이 해석된다.

for(Iterator<Peorson> itr = td.people.iterator(); iterator.hasNext(); ){
    Person p = itr.next();
    /* 생략 */
}

의도한 대로 작동시키려면 아래처럼 명시적으로 Iterator를 사용하고 remove 메서드를 호출해야 한다.

for(Iterator<Peorson> itr = td.people.iterator(); iterator.hasNext(); ){
    Person p = itr.next();
    if(p.age > 50) itr.remove();
}

removeIf를 사용할 때

프리디케이트를 인수로 넘겨 간단히 처리할 수 있다.

td.people.removeIf(p -> p.age>50);

8.2.2 replaceAll

  • replaceAll : 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소를 바꿈.

replaceAll을 사용하지 않을 때

아래와 같이 스트림을 사용해 매핑할 수 있지만 새로운 문자열 리스트를 생성한다.

TestData td = new TestData();
td.people.stream()
         .map(p -> p.name.toLowerCase())
         .collect(Collectors.toList())
         .forEach(System.out::println);

기존 컬렉션을 바꾸려면 아래와 같이 Iterator의 set() 메서드를 사용해야한다.

for(ListIterator<Person> itr = td.people.listIterator(); itr.hasNext(); ){
    Person p = itr.next();
    itr.set(changeName(p));
}

replaceAll을 사용할 때

td.people.replaceAll(ListSetProcessor::changeName);

8.2.3 sort

td.people.sort((o1, o2) -> Integer.compare(o1.age, o2.age));

8-2 Quiz

아래 코드를 짧게 줄이려면?

Map<String, Integer> movies = new HashMap<>();
movies.put("JamesBond", 20);
movies.put("Matrix", 15);
movies.put("Harry Potter", 5);
Iterator<Map.Entry<String, Integer>> iterator = movies.entrySet().iterator();
while(iterator.hasNext()){
    Map.Entry<String, Integer> entry = iterator.next();
    if(entry.getValue() < 10){
        iterator.remove();
    }
}
정답 movies.entrySet().removeIf(e -> e.getValue()<10);

8.3 맵 처리

8.3.1 forEach 메서드

맵에서 키와 값을 반복하여 확인하는 작업은 for문과 entrySet을 사용한다.

Map<String, Integer> ageOfFriends = new HashMap<>();
    ageOfFriends.put("김이름", 30);
    ageOfFriends.put("이익명", 40);
    ageOfFriends.put("박닉네임", 50);

for(Map.Entry<String, Integer> entry : ageOfFriends.entrySet()){
    String name = entry.getKey();
    Integer age = entry.getValue();
    System.out.println(name + " is " + age + " years old.");
}

이제는 forEach에 키와 값을 인수로 받는 BiConsumer를 넘겨 처리할 수 있다.

ageOfFriends.forEach((name, age)->System.out.println(name+" is "+age+" years old."));

8.3.2 정렬 메서드

HashMap을 정렬할 때, EntrySet을 별도 Collection 객체로 만들어서 Collections.sort로 정렬하지 않아도 된다. 아래와 같이 sorted 메서드를 사용하면 된다.

Map<String, String> favouriteMoview = Map.ofEntries(
        entry("Rahpael", "Star Wars"),
        entry("Cristina", "Matrix"),
        entry("Olivia", "James bond"));

favouriteMoview
        .entrySet()
        .stream()
        .sorted(Map.Entry.comparingByKey())
        //.forEach(System.out::println);
        .forEachOrdered(System.out::println);

자바8에서 HashMap은 많은 키가 같은 해시값을 반환하는 경우, O(logn) 시간이 소요되는 정렬된 트리를 이용해 동적으로 치환하여 성능을 향상시켰다.

8.3.3 getOrDefault 메서드

기존에는 찾으려는 키가 존재하지 않으면 null이 반환되므로 NullPointerException이 발생할 수 있었다. getOrDefault로 기본값을 지정해줌으로써 이 문제를 해결할 수 있다.

Map<String, String> favouriteMovies = Map.ofEntries(
        entry("Rahpael", "Star Wars"),
        entry("Cristina", "Matrix"));
System.out.println(favouriteMovies.getOrDefault("Olivia", "Matrix"));
System.out.println(favouriteMovies.getOrDefault("Timothy", "Dune"));

단, 키는 존재하고 값은 null인 경우 위 코드로도 NullPointerException을 마주할 수 있다.

8.3.4 계산 패턴

  • computeIfAbsent : 제공된 키에 해당하는 값이 없으면, 키를 이용해 새 값을 계산하고 맵에 추가
  • computeIfPresent : 제공된 키에 해당하는 값이 있으면, 키를 이용해 새 값을 계산하고 값을 변경
  • compute : 제공된 키로 새 값을 계산하고 맵에 저장한다.

파일 한줄한줄 해시값을 계산해 저장하는 컬렉션. computeIfAbsent로 이미 읽어온 줄인지 확인하고 해시값 계산해 추가.

try (Stream<String> lines = Files.lines(Paths.get("D:\\DailyLogs\\README.md"), Charset.defaultCharset())){
    Map<String, byte[]> dataToHash = new HashMap<>();
    MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
    this.messageDigest = messageDigest;

    lines.forEach(line -> dataToHash.computeIfAbsent(line, this::calculateDigest));

} catch (NoSuchAlgorithmException | IOException e) {
    e.printStackTrace();
}

친구이름/영화목록 컬렉션. 친구 이름을 검색해 없으면 새 영화목록을 생성해 추가.

Map<String, List<String>> friendsToMovies = Map.ofEntries();
String friend = "Raphael";
List<String> movies = friendsToMovies.get(friend);
if(movies==null){
    movies = new ArrayList<>();
    friendsToMovies.put(friend, movies);
}
movies.add("Star Wars");

Map<String, List<String>> friendsToMovies2 = Map.ofEntries();
friendsToMovies2.computeIfAbsent("Rahpael", name -> new ArrayList<>())
                .add("Star Wars");

8.3.5 삭제 패턴

기존에는 아래와 같이 키를 인자로 받는 remove 메서드를 사용했는데, 그전에 해당 키가 컬렉션에 존재하는지 그리고 삭제하려는 데이터의 키/값과 비교해야 했다.

Map<String, String> favouriteMovies = new HashMap<>(Map.ofEntries(
        entry("Rahpael", "Star Wars"),
        entry("Cristina", "Matrix"),
        entry("Olivia", "James bond")));

String key = "Raphael";
String value = "Jack Reacher 2";

if(favouriteMovies.containsKey(key) && Objects.equals(favouriteMovies.get(key), value)){
    favouriteMovies.remove(key);
}

이제 키/값을 인자로 넘겨 삭제할 수 있다.

favouriteMovies.remove(key, value);

8.3.6 교체 패턴

  • replaceAll : BiFunction을 적용한 결과로 각 항목의 값을 교체한다. 이 메서드는 이전에 살펴본 List의 replaceAll가 비슷한 동작을 한다.
  • replace : 키가 존재하면 맵의 값을 바꾼다. 키가 특정 값으로 매핑되었을 때만 값을 교체하는 오버로드 버전도 있다.
favouriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());

8.3.7 합침

두 맵을 합칠 때 중복된 키/값이 있다면 의도하지 않은 오류가 발생할 수 있다.

Map<String, String> family = Map.ofEntries(entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(entry("Raphael", "Star Wars"), entry("Cristina", "Matrix"));

Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends);

BiFunction을 인자로 넘겨 중복된 키를 어떻게 합칠지 미리 정할 수 있다.

Map<String, String> everyone2 = new HashMap<>(family);
friends.forEach((k,v)->everyone2.merge(k,v,(movie1, movie2)->movie1+" & "+movie2));

또는 아래와 같이 초기값을 정하고 값을 갱신해나갈 수도 있다.

moviesToCount.merge(movieName, 1L, (k, v)->v+1L);

8.4 개선된 ConcurrentHashMap

  • 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 할 수 있다.
  • 동기화된 Hashtable에 비해 읽기 쓰기 연산 성능이 월등하다. (참고로 HashMap은 비동기로 동작)

8.4.1 리듀스와 검색

  • 연산종류

    • forEach 키/값 쌍에 주어진 액션을 실행

    • redue 모든 키/값 쌍을 제공된 리듀스 함수를 이용해 결과로 합침

    • search 널이 아닌 값을 반환할 때까지 키/값에 함수를 적용

  • 연산방법

    • 키, 값으로 : forEach, reduce, search
    • 키로 : forEachKey, reduceKeys, searchKeys
    • 값으로 : forEachValue, reduceValues, searchValues
    • Map.Entry 객체로 : forEachEntry, reduceEntries, searchEntries

위 연산들은 ConcurrentHashMap의 상태를 잠그지 않는다. 이들 연산에 제공한 함수는 진행되는 동안 바뀔 수 있는 객체, 값, 순서 등에 의존하지 않아야 한다.

아울러 첫번째 인수로 병렬성 기준값(threshold)을 정해줘야 하는데, 기준값이 1이면 병렬성을 극대화하고 Long.MAX_VALUE이면 한 개 스레드로 연산을 실행한다.

ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();
long parallelismThreshod = 1;
Optional<Long> maxValue = Optional.ofNullable(map.reduceValues(parallelismThreshod, Long::max));

필요시 기본값 전용 연산을 사용하자. (reduceValuesToInt, reduceKeysToLong 등)

8.4.2 계수

  • mappingCount : long을 반환함
  • size : int를 반환

concurrentHashMap 크기가 int값을 넘어갈 때를 대비할 수 있다.

8.4.3 집합뷰

  • keySet : 키값을 집합으로 볼 수 있다. 집합/맵 변경사항이 상호간 적용된다. 단, 집합에 값을 추가하려면 keySet 생성시 인자로 기본값을 넘겨주어야 한다.
  • newKeySet : 새 맵 객체와 집합뷰를 동시에 생성한다. 기본값을 TRUE로 지정해준다.
Set<String> set = map.keySet(0L);
System.out.println(map);
System.out.println(set);

set.add("BYE");
System.out.println(map);
System.out.println(set);

map.replace("A", 100L);
map.put("HELLO", 1000L);
System.out.println(map);
System.out.println(set);

keySet은 CollectinView를 상속하는 keySetView를 반환한다.

  • CollectionView 메서드

    • clear() size() isEmpty() iterator(), containse(), remove(), toArray(), toString(), containsAll(), removeAll(), retainAll()
  • keySetView 메서드

    • keySetView 생성자에서 디폴트 값을 인자로 받는다. 만약 객체 생성시 디폴트 값을 정해주지 않으면 아래와 같이 예외를 발생시킨다.

    • public boolean add(K e) {
          V v;
          if ((v = value) == null)
              throw new UnsupportedOperationException();
          return map.putVal(e, v, true) == null;
      }
System.out.println("#1 ==========================================");
ConcurrentHashMap.KeySetView<String, Long> set = map.keySet(0L);
System.out.println(map);
System.out.println(set);
System.out.println(set.getMappedValue());

System.out.println("#2 ==========================================");
ConcurrentHashMap.KeySetView<String, Long> kset = map.keySet();
System.out.println(kset.getMappedValue());

System.out.println("#3 ==========================================");
set.add("BYE");
System.out.println(map);
System.out.println(set);

System.out.println("#4 ==========================================");
map.replace("A", 100L);
map.put("HELLO", 1000L);
System.out.println(map);
System.out.println(set);

System.out.println("#5 ==========================================");
ConcurrentHashMap.KeySetView<String, Boolean> keySet = ConcurrentHashMap.newKeySet();
keySet.add("HELLO");
System.out.println(keySet);

System.out.println("#6 ==========================================");
ConcurrentHashMap.KeySetView<String, Boolean> keySet2 = ConcurrentHashMap.newKeySet(10);
keySet.add("HELLO");
System.out.println(keySet);
System.out.println(keySet.size());
⚠️ **GitHub.com Fallback** ⚠️