안녕하세요. 다람쥐입니다.
자바 백엔드 면접을 준비하면서 관련 질문들을 정리해보려고 합니다.
지금은 머릿 속에 이곳 저곳 흩어져 있기에, 기술 면접 질문 위주로 차근차근 정리하려고 합니다!
여력이 된다면, 중간에 인성 면접도 넣어볼 예정입니다.
꼬리 질문도 되는대로 추가할 예정이니 많은 관심 부탁드립니다.
A : 다람쥐님, 프로세스와 스레드는 각각 무엇이고 어떤 차이점이 있을까요?
프로세스는 실행 중인 하나의 애플리케이션입니다.
운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행합니다.
필요한 메모리 영역은 프로그램의 코드를 저장하는 Text 영역, 전역 정적 변수들을 저장하는 Data, 지역 변수들을 저장할 Stack, 동적 메모리 할당을 받을 Heap 영역입니다.
운영체제에서 각 프로그램들은 동시에 실행할 수 있도록 멀티 프로세스로 동작합니다.
이는 CPU 자원을 잘게 쪼갠 시간 내에 점유한다는 뜻입니다.
하나의 프로세스가 CPU 자원을 점유하려는 순간 프로세스의 상태를 불러와야 합니다.
이 순간에 프로세스 상태를 교체하고 새로운 CPU 레지스터 변수들을 불러오는 작업을 컨텍스트 스위칭(Context Switching)이라고 합니다.
점유에 벗어나는 순간에 프로세스의 상태를 저장해야 합니다.
프로세스마다 각자 고유 상태를 갖고 있습니다. 이를 PCB(Process Control Block)이라고 합니다.
현재 프로세스 상태가 실행 중인지, 대기 중인지를 알려주는 Process State,
고유 프로세스의 식별값을 나타내는 Process ID,
다음 실행할 프로그램 코드 위치를 저장하는 Program Counter,
메모리 정보를 관리할 페이지 테이블과 세그먼트 테이블, CPU의 스케쥴링 큐 포인터와 우선순위 정보, I/O 정보들 등등으로 이루어져 있습니다.
반면, 스레드는 하나의 프로세스 안에서 실행되는 여러 코드 흐름을 뜻합니다.
프로세스가 갖고 있는 Text 영역, Data 영역, Heap 영역을 공유하지만, 스레드 내부에서 Stack을 별도로 할당받습니다. 어떤 프로그램이든 하나의 주요 흐름을 실행하는 Main 스레드는 가지고 있습니다.
Data 영역과 Heap 영역을 공유하기에 다른 스레드와 데이터를 통신할 수 있지만, 어느 스레드가 먼저 실행할 지 모르기에 동기화와 교착 상태에 각별한 주의가 필요합니다.
A : 프로그램 코드 실행을 하드웨어단에서 어떻게 하나요?
프로그램을 실행할 때, 적당한 메모리 위치에 프로그램이 쓸 영역을 올립니다.
프로그램이 사용할 영역은 방금 말씀드렸다시피 코드를 저장하는 영역, 전역 정적 변수들을 저장하는 영역, 지역 변수들을 저장하는 영역, 동적메모리 할당을 받을 영역으로 나누어져있습니다.
그 다음 코드를 해석하고 실행하는 장소는 CPU 입니다.
CPU 에서는 Program Counter 라는 레지스터 변수로 다음 실행할 위치의 코드를 저장하는데요.
PC 위치에 있는 코드를 메모리로부터 읽어오고 명령어를 해석하여 적절한 행동을 실행합니다.
다시 PC에 다음 코드의 위치를 저장하고 프로그램이 종료할 때 까지 반복합니다.
A : 프로그램을 메모리에 올릴 때, 적당한 메모리 공간의 위치를 어떻게 효율적으로 정할 수 있을까요?
메모리에 필요한 공간을 할당할 때, 메모리의 빈 공간을 탐색해서 할당합니다.
모든 프로그램마다 할당받을 메모리의 크기도 모두 다르기에, 무턱대고 할당하고 해제하기를 반복한다면
메모리의 빈 공간을 탐색하는데 시간이 많이 들 뿐 더러, 불필요하게 빈 메모리가 많이 생길 것 입니다.
효율적인 메모리 관리 기법으로 할당할 메모리 주소의 탐색 시간을 줄이고 빈 메모리 공간을 줄입니다.
이를 관리하는 하나의 하드웨어 단위가 있습니다. 바로 MMU (Memory Management Unit) 입니다.
최초의 메모리 할당 방법은 연속적으로 할당하는 방법입니다. MMU에서 Offset 레지스터로 CPU가 읽은 가상 주소로부터 물리 주소에 매핑을 시켜주는 역할을 합니다. 현재 프로그램 영역을 벗어나지 않도록 Limit 레지스터로 주소 접근을 제한하기도 합니다. 만약에 제한을 넘어갔을 때 인터럽트로 시스템에게 예외를 처리하도록 부탁합니다. 연속적 할당 방법의 장점은 구현이 간단합니다. 그러나 초반에 말했던 단점들은 고스란히 남아있습니다.
할당과 해제의 과정을 거치면서 메모리 중간에 빈 공간들이 생겨나는데요.
빈 공간들을 모조리 합치면 새로 들어올 프로그램의 메모리를 옮길 수 있지만,
연속적으로 할당한다는 특징으로 할당이 불가능합니다.
총 여유 메모리로 충분히 할당하고도 남지만, 실제로 할당할 수 없는 경우를 외부 단편화(External Fragmentation) 라고 합니다.
메모리 공간의 빈 공간들을 모두 없애 앞쪽으로 땡기는 Compact 기법이 있습니다. 하나 하나 메모리 영역을 복사하여 빈 공간이 없도록 반복하게 되는데, I/O 문제가 발생할 수 밖에 없습니다.
이를 해결할 방법은 메모리 영역을 쪼개는 방식입니다. 이를 페이징(Paging) 방식이라고 합니다.
할당 받은 프로그램의 물리 메모리를 특정 크기의 프레임으로 쪼개 순서에 상관없이 저장합니다.
그렇다면, 각 프로세스는 어떻게 순서대로 프로그램을 실행할 수 있을까요?
프로세스 별로 페이지 테이블을 가지고 있습니다. 논리적인 페이징 테이블로 해당하는 실제 메모리 주소에 있는 프레임을 매핑하여 위치를 찾아냅니다.
페이징 방식의 장점으로 프로그램 메모리를 잘개 쪼개 효율적으로 메모리에 저장하여 외부 단편화를 해소한다는 점이고요. 페이징 방식의 단점으로는 특정 크기의 프레임으로 메모리 영역을 분리하는데요.
만약에 메모리 크기가 딱 맞지 않다면은 여전히 빈 공간이 생깁니다.
이를 내부 단편화(Internal Fragmentation)라고 합니다.
프레임 크기를 줄일수록 페이지 테이블이 커지고 그에 따른 오버헤드는 훨씬 빈번히 발생할 것 입니다.
물리 메모리에 접근할 때 늘 페이지 테이블에 접근해서 매핑된 물리 메모리에, 총 두 번 접근합니다.
이를 개선하기 위해 연관 메모리인 TLB(Translation Look-aside Buffer, 변환 색인 버퍼)를 사용합니다.
페이지 테이블을 대상으로 일종의 캐시 역할을 해줍니다.
더 나아가 메모리 크기가 커지면서 페이지 테이블을 여러 단계로 계층화 시킨다든지, 해쉬 테이블을 이용한다든지, 하나의 페이지 테이블로 통합시킨다든지 하는 방법들이 나왔습니다.
자세히는 모르지만 현대에 와서는 멀티 코어 시대인만큼 병렬화를 적극 이용하지 않았을까 싶습니다.
A : 프로그램 코드를 읽어올 때 CPU에서 어떻게 효과적으로 읽을 수 있는지 생각해보셨나요?
CPU와 메모리의 연산 속도는 다르기에 CPU 에서도 효과적으로 메모리로부터 데이터를 읽는 방법이 필요합니다. CPU 내부에 여러 캐시 메모리를 장착하고 있어 최대한 CPU 사이클보다 느린 메모리로부터 데이터나 코드를 불러오는 것을 지양하면서 캐시 메모리를 적극 활용해 속도를 개선합니다.
코드를 읽을 때, 메모리에서 다음에 실행할 코드 뭉치들을 미리 불러와 캐시 메모리에 저장합니다.
캐시 메모리에 저장한 명렁어들을 소진될 때 까지 순차적으로 실행해 속도를 개선합니다.
B : Context Switching이 일어날 때 운영체제 내에서 어떻게 동작하나요?
현재 프로그램 코드에서 다른 프로그램 코드 영역으로 옮길 때, 현재 프로세스의 레지스터 정보와 PCB를 저장합니다. 운영체제 내에서 레지스터 정보와 PCB를 저장하는 테이블에 등록합니다. 그리고 다른 프로세스의 정보를 탐색해 불러옵니다. 다시 원래 프로세스로 돌아올 때 저장했던 테이블에서 탐색해 불러옵니다.
C : 스레드 동기화는 어떻게 이루어지나요?
스레드 간에 객체를 공유하여 수정할 시, 다른 스레드에도 그 정보가 반영되지 않아 예상치 못한 오류가 일어날 수 있습니다.
멀티 스레드 환경에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역이라고 합니다.
자바에서는 이를 위해 동기화(synchronized) 메소드와 동기화 블록을 제공합니다.
스레드 내부의 동기화 메소드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 해당 임계 영역 코드를 실행하지 못하도록 합니다.
C : 자바에서 스레드의 상태를 제어하는 방법을 알려주세요.
스레드는 다음과 같은 상태를 가집니다, NEW, RUNNABLE, TIMED_WAITING, BLOCKED, TERMINATED.
스레드 객체를 생성하고 start() 메소드가 호출되지 않은 상태일 때 NEW 상태입니다.
RUNNABLE 은 실행 상태로 언제든지 갈 수 있는 상태입니다.
WAITING은 다른 스레드가 notify 해줄 때 까지 기다리는 상태입니다.
TIMED_WAITING은 주어진 시간 동안 기다리는 상태입니다.
BLOCKED는 사용하고자 하는 객체의 락이 풀릴 때 까지 기다리는 상태입니다.
TERMINATED는 실행을 마친 상태입니다.
스레드의 wait() 메소드는 동기화 블록 내에서 스레드를 WAITING 상태로 만듭니다. 매개값으로 주어진 시간이 지나면 자동적으로 RUNNABLE 상태가 된다. 주어지지 않으면, notify(), notifyAll() 메소드로 RUNNABLE 상태로 이동한다.
notify(), notifyAll() 메소드는 동기화 블록 내에서 wait() 메소드에 의해 WAITING 상태에 있는 스레드들을 RUNNABLE 상태로 만든다.
join() 메소드를 호출한 스레드는 WAITING 상태로 만든다. RUNNABLE 상태로 가기 위해서 join() 메소드를 호출 받은 스레드가 종료되거나, 매개값으로 주어진 시간이 지나야 한다.
forest.grass : 멀티 스레드 환경이면 무조건 동기화를 사용해야 하나요?
공유 객체를 사용할 일이 없다면, 동기화 메소드와 동기화 블록을 사용할 이유가 없지 않을까 싶습니다.
공유 객체란, 외부에서 들어오는 레퍼런스 타입의 객체 라든지, 전역으로 관리되는 객체 등의 외부에서도 접근 가능한 객체를 뜻합니다.
forest.grass : 스프링 프레임워크에서 여러 스레드가 컨트롤러라는 공유 객체를 쓸 텐데, 이는 스레드에 안전한가요?
기본적으로 스프링 빈에서 객체를 생성할 때 싱글톤으로 만들고 Application Context 에 저장합니다.
서블릿은 대부분 멀티 스레딩 환경에서 싱글톤을 동작하고 서블릿 클래스 하나 당 하나의 객체를 생성해, 클라이언트 요청 처리를 맡은 스레드들이 공유해서 사용합니다.
스프링 빈에서 Thread - safe 를 보장하려면 무상태성(Stateless)을 지켜야 합니다.
상태 정보를 클래스 내부에 가지고 있으면 안됩니다. 다른 싱글톤 빈을 저장하는 용도면은 사용 가능하다.
만약 객체 필드에 다른 스레드가 값을 변경할 여지가 있는 객체가 있다면, Thread-safe 하지 않으므로 공유 불가능한 스택 영역으로 옮기는 등의 조치를 취해야 합니다.
K.JY : 스레드 개수가 많아지면 더 효율적일까요?
아닙니다. 과도한 스레드 개수로 애플리케이션의 성능이 저하될 수 있습니다.
스레드 개수가 증가할수록, 스레드 생성과 스케줄링으로 CPU 점유율이 올라가고 메모리 사용량이 늘어납니다.
스레드의 폭증을 막으려면 스레드풀(ThreadPool)을 사용해야 합니다.
스레드를 제한된 개수만큼 정해 놓고 작업 큐에 들어오는 작업들을 하나씩 스레드가 맡아 처리합니다.
작업 처리가 끝난 스레드는 다시 작업 큐에서 새로운 작업을 가져와 처리합니다.
자바에서는 java.util.concurrent 패키지에서 ExecutorService 인터페이스와 Executors 클래스를 제공합니다.
Executors 클래스의 정적 메소드로 다양한 ExecutorService 구현 객체인 스레드풀을 만들 수 있습니다.
스레드풀의 종류는 세 가지가 있습니다.
newCachedThreadPool()은 동적 크기로 처리할 작업이 있을 때 새 스레드를 생성합니다. 60초 동안 추가된 스레드가 아무 작업을 하지 않으면 추가된 스레드를 종료하고 풀에서 제거합니다.
newFixedThreadPool()은 최대 스레드 개수를 설정하며, 유후 스레드가 있더라도 스레드 개수가 줄지 않습니다.
newSingleThreadPool()은 하나의 스레드를 생성하며, 작업이 차례대로 실행되며 스레드 안전하다.
가상으로 진행한 기술 인터뷰입니다.
틀린 부분과 보충할 부분이 있다면 언제든 댓글 달아주세요.
궁금한 면접 질문도 댓글 달아주세요 :)
댓글