이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
기초개념 › Java
락은 무엇이고, 없으면 무슨 일이 생기는가
락은 "한 번에 한 스레드만 이 구역을 실행한다"는 표시입니다. 이 코드에서 락을 거는 부분은 add 메서드 선언 앞의 synchronized입니다. add가 하는 일은 count++ 한 줄인데, 이 한 줄은 count의 값을 읽고, 1을 더하고, 더한 값을 count에 쓰는 세 동작입니다. 스레드 두 개가 같은 count를 동시에 고치면, 한 스레드가 값을 읽은 뒤 쓰기 전에 다른 스레드가 count를 바꿔 놓아 한쪽의 증가가 사라질 수 있습니다. synchronized가 붙으면 스레드는 add에 들어가기 전에 락을 잡아야 하고, 락은 한 번에 한 스레드만 잡을 수 있어서 다른 스레드는 앞 스레드가 add를 나올 때까지 기다립니다. 이 실습은 synchronized를 붙였다 떼고 스레드를 2개와 1개로 바꿔 가며 같은 코드를 컴파일하고 실제로 실행해, 마지막에 출력되는 count를 봅니다. (스레드를 만들어 start와 join으로 다루는 것은 "스레드" 실습에서 다룹니다. static은 count와 add가 프로그램에 하나씩만 있다는 뜻으로 보고, 이 실습에서는 자세히 다루지 않습니다.)
실행으로 대조 · JDK 21에서 네 가지 조합을 javax.tools로 컴파일하고 자식 java 프로세스로 30번씩 실행해, 마지막 count를 대조(락 없이 스레드 2개이면 30번 모두 200000보다 작음)
public class Main {
// 두 스레드가 함께 고치는 변수. 프로그램에 하나뿐이다
static int count = 0;
// 락: synchronized를 붙이면 한 번에 한 스레드만 실행한다
static synchronized void add() {
// count++는 읽기, 더하기, 쓰기 세 동작이다
count++;
}
public static void main(String[] args) throws Exception {
// 스레드가 할 일: add()를 100000번 부른다
Runnable job = () -> {
for (int i = 0; i < 100000; i++) {
add();
}
};
Thread t1 = new Thread(job);
// 두 번째 스레드: 같은 일을 한다
Thread t2 = new Thread(job);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
컴파일러와 실행 환경은 JDK 21.0.12.1(javac 21)입니다. 네 가지 조합을 모두, 화면의 코드와 같은 소스(줄 단위로 같은 글)를 javax.tools로 컴파일하고, 이 JDK의 java를 자식 프로세스로 띄워 조합마다 30번씩 실제로 실행해 마지막에 출력된 count를 모아 대조했습니다. 120번의 실행 모두 종료 코드는 0이고 표준 오류는 비어 있었습니다. synchronized가 있는 두 조합은 스레드 2개일 때 30번 모두 200000, 1개일 때 30번 모두 100000이었습니다. synchronized가 없고 스레드가 1개인 조합도 30번 모두 100000이었습니다. synchronized가 없고 스레드가 2개인 조합은 30번 모두 200000보다 작았고, 서로 다른 값이 30가지(119684에서 155244 사이)였습니다. 그래서 이 조합의 결과 칸은 고정된 숫자가 아니라 "200000보다 작은 값 (실행마다 다름)"으로 적었습니다. 이 PC(논리 프로세서 12개)에서 본 결과이고, 얼마나 사라지는지는 컴퓨터와 실행 때마다 달라질 수 있어 사라지는 양을 화면에서 단정하지 않았습니다. 장면에 나오는 5, 6, 7이라는 숫자와 t1, t2가 끼어드는 순서는 원리를 보이려고 고른 한 예이고, 실제 실행에서 그 순서를 관찰한 것은 아닙니다. 실제 실행에서 사라진 증가는 이 예의 한 번보다 훨씬 많았는데(기대한 값보다 44756에서 80316 모자랐습니다), 왜 그만큼 사라지는지는 따로 쪼개 확인하지 않았습니다. 이 실습이 확인한 것은 "사라진다"는 사실과 세 동작으로 나뉜다는 사실이고, 그 두 가지가 사라진 양을 어떻게 만드는지는 확인하지 않았습니다. 단계 문장이 기대는 사실은 따로 만든 소스로 확인했습니다. 첫째, 같은 소스에서 add() 안의 count++를 javap로 보면 getstatic(읽기), iconst_1과 iadd(1을 더하기), putstatic(쓰기)의 순서입니다. 둘째, t1이 static synchronized 메서드 안에서 락을 쥐고 있는 동안 t2가 같은 메서드를 부르면 t2의 상태가 BLOCKED이고, 그 락이 Main 클래스 객체의 것임을 Thread.holdsLock(Main.class)가 true로 보였으며, 둘이 끝난 뒤 count는 2였습니다(30번 모두 같았습니다). 문서는 Java Language Specification(Java SE 21)을 직접 열어 확인했습니다. 한 번에 한 스레드만 모니터의 락을 쥘 수 있고 다른 스레드는 쥘 수 있을 때까지 멈춘다는 것과 static 메서드는 Class 객체의 모니터를 쓴다는 것은 17.1절을, synchronized 메서드가 실행 전에 모니터를 얻고 static 메서드는 Class 객체의 모니터를, 인스턴스 메서드는 this의 모니터를 쓴다는 것은 8.4.3.6절을, 락을 푸는 것이 메서드가 끝날 때 자동으로 일어난다는 것은 8장 첫머리의 설명을 따랐습니다. 8.4.3.6절 자체에는 풀리는 때가 적혀 있지 않았습니다. 한 스레드가 락을 푼 것은 그 뒤에 같은 락을 잡는 스레드보다 먼저 일어난 것으로 정해진다는 것은 17.4.5절의 내용이며 이 실습에서는 값이 보이는 문제를 따로 다루지 않았습니다. count++가 값을 가져오고, 1을 더하고, 다시 저장하는 세 단계로 나뉘며 두 스레드의 단계가 섞이면 갱신이 사라질 수 있다는 것은 Java Tutorials의 Synchronization 절 중 Thread Interference와 Synchronized Methods를 열어 확인했습니다. 다만 어느 문서도 이 코드에서 몇 번이 사라지는지는 정하지 않으며, 그것은 이 JDK에서 실행해 본 값입니다. 문서는 웹 페이지 요약을 거쳐 읽었고 원문 전체를 직접 읽지는 않았습니다. 화면의 코드는 java.lang 안의 클래스만 쓰므로 import 줄이 없습니다. 이 밖에 synchronized 블록, 객체마다 따로 있는 락(인스턴스 메서드), volatile과 AtomicInteger, 락이 풀리는 순서와 공정성, 락이 서로를 기다려 멈추는 교착 상태는 다루지 않았고, 사라지는 원리와 락의 비교는 "세부 동작 실습: 경쟁 상태", 교착 상태는 "세부 동작 실습: 교착 상태"에서 다룹니다.
줄을 그은 곳은 처음 코드에서 지운 것이고, 색을 칠한 곳은 새로 쓴 것입니다.