동시에 하나의 데이터에 여러명의 사용자가 접근하는 경우, 문제가 생길 여지가 있습니다. 이러한 문제의 대표적인 예시가 싱글톤에서 상태를 가지고 있는 경우입니다.
하나의 싱글톤 인스턴스를 동시에 여러 사용자가 상태를 변경하게 되면, 정합성이 맞지 않는 경우가 존재합니다. 다음 예시를 살펴보겠습니다.
class Wallet {
public static final Wallet INSTANCE = new Wallet();
private long bill;
private Wallet() {
this.bill = 0;
}
public void putInto(long bill) {
this.bill += bill;
}
}
두명의 사용자가 동시에 각각 putInto 메소드를 실행시킨다고 가정해 봅시다. 결과적으로 bill 필드의 값은 두 사용자의 putInto 메소드로 인한 변화중 하나만 적용되게 될 것입니다. 왜냐하면 따로 동기화 처리가 되어있지 않기 때문에 메소드를 실행시키는 시점엔 같은 필드값을 읽어들이기 때문입니다.
테스트
그렇다면, 이러한 테스트는 어떻게 해볼 수 있을까요? 두개의 쓰레드를 동시에 putInto() 메소드를 실행하도록 하면 될 것 같습니다. 코드는 다음과 같습니다.
예상과는 다르게, 3000원이 나오지 않고 0원이 나오는 문제가 발생했습니다. 무엇이 문제였을까요? 테스트에서 assertThat을 호출하는 시점이 쓰레드의 작업이 모두 끝난 시점이 아닐수도 있기 때문입니다. 따라서 쓰레드의 작업이 모두 끝난 다음에 테스트를 하도록 바꿔주어야 합니다.
그렇다면 이러한 문제는 어떻게 해결할 수 있을까요? 이 때 사용하는 것이 CountdownLatch입니다. CountdownLatch의 countDown() 메서드와 await() 메서드를 사용하여 위의 문제를 간단하게 해결할 수 있습니다.
CountdownLatch
생성자에 원하는 count를 명시하고, countDown() 메서드로 count를 하나씩 감소시킵니다. await() 메서드는 CountdownLatch가 0이 되기만을 기다리는 메소드입니다. 따라서 각각의 쓰레드에서 countdownLatch를 하나씩 감소시키고, 테스트 쓰레드(Test worker)에서는 countdownLatch가 0이 되기를 기다리도록 작성하면 될 것 같습니다.
그럼 CountdownLatch를 사용하여 원하는 결과가 나오도록 테스트를 수정해 보겠습니다.
Synchronization is built around an internal entity known as the intrinsic lock or monitor lock (The API specification often refers to this entity simply as a "monitor.")
CAS(Compare-and-Swap)을 활용하여 데이터 무결성을 보장합니다. CAS는 세개의 피연산자에서 작동합니다. 하나는 작동할 메모리 위치, 하나는 변수의 기존 기대값, 마지막 하나는 설정해야 하는 새 값입니다.
즉, CAS는 메모리 위치에 있는 값을 새 값으로 업데이트하는 방법입니다. 단, 이전 값이 변수의 기존 기대값과 같아야 합니다.
이번 예시에서는 AtomicInteger의 accumulateAndGet() 메서드 구조를 통해 어떻게 동시성을 유지하는지 확인해 보겠습니다.
public final int accumulateAndGet(int x,
IntBinaryOperator accumulatorFunction) {
int prev = get(), next = 0;
for (boolean haveNext = false;;) {
if (!haveNext)
next = accumulatorFunction.applyAsInt(prev, x);
if (weakCompareAndSetVolatile(prev, next))
return next;
haveNext = (prev == (prev = get()))
weakCompareAndSetVolatile(expected, newValue) 메서드는 현재 값이 매개변수로 전달된 expectedValue와 같은 경우 AtomicReference에 대한 값을 원자적으로 newValue로 설정하는데 사용됩니다. 즉, 현재 값이 prev인 경우에만 next를 리턴하게 됩니다.
haveNext는 기존 값이 변경되었는지의 여부입니다.
(prev == (prev = get())) 에서 기존 값과 실제 저장되어 있는 데이터를 비교하며 실제 저장되어 있는 데이터로 prev를 갱신합니다.
haveNext가 False인 경우가 실제 저장된 데이터와 현재 데이터가 다른 것이므로, accumulatorFunction.applyAsInt 메서드의 결과값을 새롭게 next에 넣어줍니다.
결국 최신 값으로 prev가 설정이 되고 나서야 weakCompareAndSetVolatile 메서드에 의해 next로 값이 설정되고 리턴됩니다.
Vector 인스턴스가 동기화되는 것이 아니라 작업이 동기화되는 것이기 때문에 동시성을 보장하지 않습니다.
한 쓰레드가 Vector을 for문으로 반복하고, 다른 쓰레드가 Vector의 데이터를 수정한다면 ConcurrentModificationException 예외가 발생합니다.
즉, 서로 다른 두 작업이 동시에 수행될 수 있는 문제가 있습니다. 동시에 수행될 수 있다면 차라리 ArrayList를 쓰거나 Collections.synchronizedList() 메서드로 감싸주는 것이 더 좋습니다.
Collections.synchronizedList()는 각 메소드에서 mutex로 동기화를 처리하여 한 메서드가 실행중일 때 다른 메서드가 접근할 수 없습니다.
1. ArrayList
데이터를 배열에 저장합니다.
리스트가 꽉 차게 되면 배열을 늘려줍니다.
배열의 크기는 이전 배열 크기의 1.5배로 늘어납니다.
oldCapacity >> 1 ⇒ oldCapacity * 0.5
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity
: hugeCapacity(minCapacity);
}
따로 동시성이 처리되지는 않습니다.
2. LinkedList
List 뿐만 아니라 Queue를 구현합니다.
add() 메서드 호출시 마지막 노드에 추가 노드를 삽입한다.
노드의 경우는, 양방향 노드입니다. prev, next를 저장합니다.
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
get() 메서드 호출시 ArrayList의 경우는 배열의 index로 접근하지만, LinkedList의 경우는 첫번째에서부터 n번 이동하여 데이터를 가져옵니다. 이로 인해 조회 속도가 ArrayList에 비해선 느릴 수 있습니다.
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
public boolean contains(Object o) { return map.containsKey(o); }
2. TreeSet
내부적으로 NavigableMap을 가지고 있습니다. 기본으로 사용하는 구현체는 TreeMap입니다.
데이터를 추가하거나 데이터에 값을 가지고 있는지 유무는 HashSet과 마찬가지로 Map의 메서드를 사용합니다.
TreeMap의 성질로 인해 데이터가 정렬되어 있습니다. 이로 인해 compareTo 메서드를 잘 재정의 해두어야 합니다.
Key를 기준으로 오름차순, 내림차순으로 정렬된 Map입니다.
NavigableMap
3. LinkedHashSet
값이 들어간 순서대로 출력 가능합니다.
Map
키-값 형태의 데이터를 저장합니다.
1. HashMap
해시를 기준으로 키-값을 저장합니다. 데이터 저장 로직인 putVal() 메서드의 코드를 하나하나 분석해 보겠습니다.
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
초기 테이블 설정이 되어있지 않은 경우 테이블을 설정합니다. resize() 메서드는 테이블 크기를 초기화하거나 2배로 재조정하며 초기 테이블이 설정되어 있지 않은 경우 입력된 초기 크기로 설정하거나 초기값을 16으로 설정합니다.
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
기존에 동일한 해시값으로 데이터가 들어오지 않았다면 newNode() 메서드로 새로운 노드를 만들어서 넣어줍니다. 존재한다면 두 분기점이 존재합니다. equals()와 hashcode가 모두 같은 경우는 같은 데이터가 들어온 것으로 간주합니다. 타입이 TreeNode인 경우는 테이블이 아닌 트리구조인 형태이며, TreeNode가 아닌 경우, 체이닝으로 되어있으므로 다음 링크로 이동해 나가면서 같은 데이터가 있는지 찾고, 없다면 맨 뒤에 데이터를 넣어줍니다.
단, 여기서 TREEIFY_THRESHOLD - 1 이상인 경우 테이블 구조에서 트리구조로 변경합니다. TREEIFY_THRESHOLD는 8이므로, 동일한 해쉬값인 데이터가 8개 들어오는 순간 트리구조로 변환할 것 같지만, 그렇지 않습니다.
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
사실상, 테이블의 크기가 MIN_TREEIFY_CAPACITY(64) 아래인 경우에는 resize() 메서드를 호출하여 테이블의 크기를 늘려주기만 하고, 기존에 있는 데이터를 다시 테이블에 넣어주기만 합니다. 크기가 64 이상인 경우에만 트리구조로 변경시켜 줍니다. 왜 이런 구조가 되었을까요?
왜냐하면 트리를 사용하는건 최대한 뒤로 미루어야 하기 때문입니다. 해시 테이블의 경우는 이론상 O(1)의 시간이 걸리나, 트리를 사용한 탐색의 경우 O(log n)의 시간이 걸리기 때문입니다. 일반적으로 작은 테이블 크기의 경우 확장을 통해 해시 충돌을 해결할 가능성이 높습니다.
그렇다면 왜 트리를 사용하게 될까요? 이 부분은 개인적인 생각입니다. 해시 충돌이 잦은 경우를 생각해 봅시다. 충분하게 테이블 크기를 늘렸음에도 불구하고 해시 충돌이 매우 잦게 일어나게 되면 테이블에 데이터가 균일하게 들어가지 않고, 하나의 버킷에 데이터가 몰리는 현상이 일어나게 됩니다. 이러한 경우 결국 해시 테이블을 사용했지만 검색 속도가 O(n)에 가깝게 될겁니다. 따라서 트리를 사용하게 되면 O(log n)의 시간이 걸리니 오히려 트리가 더 빠르게 동작할 수도 있을 것 같습니다.
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
마지막으로, 이미 존재한 경우에 대해서 처리하고 저장된 데이터의 수가 임계치보다 커진다면 크기를 더 확장시켜 줍니다.
2. TreeMap
정렬된 키값 순으로 데이터가 조회됩니다.
Red-Black Tree를 사용합니다. 적절한 위치에 데이터를 삽입한 다음 Red-Black 후처리를 진행합니다. Red-Black Tree에 대해선 추후에 다시 다룰 예정입니다.
내부에 Head, Tail을 따로 두어 Map이 변경될때마다 같이 조절하여 저장 순서를 유지합니다. 구현 로직은 HashMap의 메서드들을확장해서 사용합니다.
put 메서드 기준으로 살펴보겠습니다. HashMap의 putVal메서드에서 사용하는 newNode 메서드를 재정의해서 사용합니다.
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<>(hash, key, value, e);
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
linkNodeLast(p)라는 메서드가 살짝 낀걸 볼 수 있습니다. 이 친구는 무엇을 하는 역할일까요? Tail의 링크에 p를 연결시켜주고 Tail을 p로 설정해주는걸 알 수 있습니다. 따라서 Head에서 시작해서 링크를 따라 Tail로 이동하면 데이터를 넣은 순서대로 조회가 가능합니다.
다음은 LinkedkeySet의 forEach 메서드의 구현 코드입니다.
public final void forEach(Consumer<? super K> action) {
if (action == null)
throw new NullPointerException();
int mc = modCount;
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
action.accept(e.key);
if (modCount != mc)
throw new ConcurrentModificationException();
}
실제로 head에서 시작해서 null이 될 때 까지(tail의 after은 null입니다.) 이동하면서 action을 실행시켜 주는걸 볼 수 있습니다.
2022년 10월 31일 월요일, 저는 백엔드 이프와 함께 자바를 학습하고 있었습니다. 그러던 중 이프가 흥미로운 주제를 던졌습니다. Final 키워드가 실제로 성능이 향상된다는 점이었습니다.
처음 들었을 때는, 말도 안되는 소리라고 생각했지만.. 학습해 보니 성능이 향상될 수도 있다는 점을 알게 되었습니다.
우선, Final 키워드를 붙인 코드입니다.
@Test
public void sum1() {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
final String a = "a";
final String b = "b";
String c = a + b;
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
다음으로, Final 키워드를 붙이지 않은 코드입니다. 동일한 코드를 사용하며 정말 final 키워드만 빼두었습니다.
@Test
void test2() {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
String a = "a";
String b = "b";
String c = a + b;
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
생각보다 위 코드의 경우는 성능 차이가 나타나는 것을 볼 수 있습니다. 그렇다면 왜 이런 결과가 나타났을까요? 컴파일 시 최적화가 일어나기 때문입니다. 실제로 생성된 바이트코드를 살펴보겠습니다.
우선 Final 키워드를 사용한 코드의 바이트코드를 살펴보겠습니다.
예상은 + 연산을 해줄 것이라고 생각했지만, 실제 결과는 따로 연산을 수행하는 것이 아닌 “a”와 “b”가 더해진 결과인 “ab”를 곧바로 넣어줍니다.
다음으로, Final 키워드를 사용하지 않은 코드의 바이트코드를 살펴보겠습니다.
Final 키워드를 사용하지 않으니 우리가 생각한 결과인 a와 b를 불러오고, a와 b를 makeConcatWithConstants 메서드를 통해 더해주는 모습을 볼 수 있었습니다.
결론
즉, 결론적으로 모든 경우에서 무조건 성능이 좋아진다고 보기는 어렵지만, 위와 같은 경우 Final 키워드를 사용하니 컴파일러가 최적화 해주는 모습을 볼 수 있었습니다.
JVM이란 Java 응용 프로그램을 실행하는 런타임 엔진 역할을 합니다. JVM에서 실제로 자바 코드상에 존재하는 main() 메서드를 호출하며, JRE(Java Runtime Environment)의 일부입니다.
Java 어플리케이션은 한 시스템에서 Java 코드를 작성하더라도 변경 없이 다른 Java를 지원하는 시스템에서 실행할 수 있는 성질이 있는데, JVM을 사용하기 때문입니다.
JVM의 동작 과정
Java 파일을 컴파일할 때, .java 파일에 있는 동일한 클래스 이름을 가진 클래스 파일이 컴파일러에 의해 생성됩니다. class 파일은 실행할 때 다양한 단계를 거치는데, 이 단계들이 바로 전체 JVM의 동작 과정입니다.
Class Loader
주로 세가지 역할을 담당합니다.
Loading
Linking
Initialization
Loading
ClassLoader가 필요한 클래스들을 불러와서 적재시키는 과정입니다.
Initialization
초기화 단계에서 모든 정적 변수는 코드 및 정적 블록(있는 경우)에 정의된 값으로 할당됩니다. 클래스 내부에서 위에서 아래로, 클래스 계층에서 부모에서 자식으로 실행됩니다. 일반적으로 세개의 클래스 로더가 존재합니다.
클래스를 불러올 때, Bootstrap Class Loader에서 찾을 수 없다면 Extension ClassLoader, Extension ClassLoader에서 찾을수 없으면 Application ClassLoader을 찾아 보고, 최종적으로 없다면 ClassNotFoundException이 발생합니다.
Class Loader의 종류
Bootstrap Class loader
JAVA_HOME/jre/lib 디렉토리에 존재하는 핵심 자바 API 클래스를 로드합니다.
Extension Class loader
확장 디렉토리(JAVA_HOME/jre/lib/ext) 또는 기타 디렉토리(java.ext.dirs 시스템 속성에 의해 지정)에 있는 클래스들을 로드합니다.
ExtClassLoader 클래스에 의해 구현됩니다.
System/Application class loader
Extension Class loader의 자식입니다.
애플리케이션 클래스 경로에서 클래스를 불러오는 역할을 합니다. 즉, 개발자들이 작성한 클래스 파일이 불러와 집니다.
내부적으로 java.class.path에 매핑된 환경 변수를 사용하며, AppClassLoader 클래스에 의해 구현됩니다.
jdbc 템플릿을 직접 구현해보는 미션을 하다가 쿼리를 실행하는 모든 메서드에 try-with-resource가 있는 문제점이 발견되었습니다. 중복을 처리하기 위해 처음에는 try-with-resource 부분을 Function 함수형 인터페이스를 사용하여서 처리하도록 구현 해 보았습니다.
이 때, 한가지 문제점이 도출되었습니다.
Function 함수형 인터페이스에는 throws가 정의되어 있지 않기 때문에, throws로 예외를 상위로 넘겨줄 수 없고, try-catch로 예외를 모두 핸들링해주어야 하는 문제점이 발생했습니다. 물론 try-catch 블록으로 해결할 수 있겠지만, 그렇다면 doExecute 메서드를 사용하는 모든 메서드에서 try-catch 블록을 써주어야 할 테니 코드가 좀 지저분해 질 거 같다고 생각이 들었습니다.
해결하고자 구글링을 조금 해보고 찾아보니 간단하게 사용자 정의 함수형 인터페이스를 사용하면 된다고 합니다. 사용자 정의 함수형 인터페이스의 메서드에 throws를 붙여주면 됩니다.
스프링을 참고하면서 만들다 보니, 실제 클래스명을 그대로 사용했습니다.
사용자 정의 함수형 인터페이스를 만들어 주었다면, 이를 활용하도록 코드를 변경해 봅시다.
더이상 빨간줄이 나타나지 않는 모습을 볼 수 있습니다. try-catch 블록을 따로 써줄 필요 없어 코드가 더욱 깔끔해졌네요.
우아한 테크코스 미션을 진행하던 도중, TDD로 개발을 하는데 랜덤 데이터를 테스트해야 하는 경우가 생겼다. 이 때, 랜덤 데이터를 뽑는걸 어떻게 테스트해야 할까 고민하다가 좀 검색하고 찾아보니 랜덤 데이터를 사용하는 클래스의 생성자에 랜덤 데이터를 뽑는 인터페이스를 받도록 구현하고 테스트 클래스에서는 구현 객체를 특정 정수를 리턴하도록 구현하였다. 그 결과 어느정도 만족스럽게 된 것 같다.
RandomUtil 인터페이스 생성
우선, 랜덤값을 발생시키는 generate 메소드를 가지는 RandomUtil 인터페이스를 만들어 둔다.
public interface RandomUtil {
public int generate();
}
클래스 생성자에 RandomUtil 받도록 구현
Example 클래스에서 RandomUtil의 구현 객체에 의존하도록 생성자에 RandomUtil을 추가해 준다.
public class Example {
RandomUtil randomUtil;
public Example(RandomUtil randomUtil) {
this.randomUtil = randomUtil;
}
public int generate() {
return randomUtil.generate();
}
}
테스트 케이스 작성
위와 같이 생성자에서 RandomUtil을 추가해 주면, 넘겨준 RandomUtil을 조정해주는 방식을 통해 원하는대로 테스트를 진행할 수 있다. 따라서, Random의 결과를 조정해 줄 수 있으므로, 각각의 결과에 따라 테스트 케이스를 작성하는 방식으로 진행하였다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.*;
class ExampleTest {
@Test
@DisplayName("generate의 결과로 1이 나옴")
void generate_1() {
Example example = new Example(() -> 1);
assertEquals(example.generate(), 1);
}
@Test
@DisplayName("generate의 결과로 5가 나옴")
void generate_5() {
Example example = new Example(() -> 5);
assertEquals(example.generate(), 5);
}
@Test
@DisplayName("generate의 결과로 난수가 나옴")
void generate_Random() {
Example example = new Example(() -> {
Random random = new Random();
return random.nextInt(100);
});
int randomData = example.generate();
assertTrue(0 <= randomData && randomData < 100);
}
}
이번 우아한테크코스 시간에는 테스트 작성 기법과 TDD에 관하여서 학습 및 실습을 하였다. 실제로 페어와 TDD로 의식적으로 개발을 하려다 보니, 테스트 케이스를 어떻게 작성해야 할 지 고민을 많이 하게 되는 것 같고, 무엇보다 junit을 능숙하게 사용하지 못하다 보니 턱턱 막히는 경우가 많아 따로 정리해보려고 한다ㅎㅎ
Dependency
처음에는 왜 자동완성이 안될까 고민했었는데 Dependency 설정을 하지 않아서 문제가 있었음을 알 수 있었다..ㅋㅋ
최대공약수를 구할 때, 여지껏 두개의 수를 입력받아서 나머지와 나눗셈 연산을 통해 구하는 방법을 사용했지만 최근에는 파이썬에서는 Math.gcd라는 함수가 있어 잘 사용했지만, Java에서는 Math 클래스에 gcd 함수가 없더라.. 내장함수를 쓰면 편하니까 비슷한 함수가 있나 찾아보니 BigInteger라는 클래스에 gcd라는 함수가 있었다!
BigInteger 클래스를 사용해서 최대공약수를 구하려면 다음과 같은 단계를 거쳐 최대공약수를 구할 수 있다.