컴퓨터 속의 정수는 켜짐과 꺼짐을 나타내는 스위치가 한 줄로 늘어선 것이다. 비트 연산은 수를 크기로 보지 않고 이 스위치 줄로 보아, 같은 자리의 스위치끼리 짝지어 계산하거나 줄 전체를 옆으로 민다. 스위치 하나하나를 따로 켜고 끄고 확인할 수 있고, 2를 곱하거나 나누는 일을 빠르게 한다. 정수에만 쓸 수 있고, 음수를 오른쪽으로 밀 때는 빈자리를 무엇으로 채우느냐에 따라 답이 전혀 달라진다.
예시로 보기
파일 하나에 “읽기 가능, 쓰기 가능, 실행 가능”을 적어 두려 한다. boolean 변수 셋을 둘 수도 있다. 하지만 예/아니오 하나는 스위치 하나, 곧 1비트면 충분하다. 정수 하나의 비트 셋에 나눠 담으면 변수 하나로 끝난다. 그러려면 정수의 특정 비트만 건드리는 연산이 필요하다.
45 & 25부터 풀어 본다. 두 수를 2진수로 적고 같은 자리끼리 본다. 둘 다 1인 자리만 1이다.
45 = 0 0 1 0 1 1 0 1
25 = 0 0 0 1 1 0 0 1
--------------- & (둘 다 1일 때만 1)
0 0 0 0 1 0 0 1 = 9
45는 \(32 + 8 + 4 + 1\)이고 25는 \(16 + 8 + 1\)이다. 둘에 모두 있는 것은 8과 1이라 답이 \(8 + 1 = 9\)다. 10진수를 2진수로 바꾸는 법은 진법과 자릿수에 있다.
비트 논리 연산
비트 하나하나에 논리 연산을 한다. 1을 참, 0을 거짓으로 보면 논리 연산자와 규칙이 같다.
| 연산 | 기호 | 결과 비트가 1인 때 | 45와 25로 하면 |
|---|---|---|---|
| AND | & |
두 비트가 모두 1 | 00001001 = 9 |
| OR | \| |
두 비트 중 하나라도 1 | 00111101 = 61 |
| XOR | ^ |
두 비트가 서로 다름 | 00110100 = 52 |
| NOT | ~ |
원래 비트가 0 (모든 비트를 뒤집는다) | ~45 = 11010010 = −46 |
~45가 왜 −46일까? 모든 비트를 뒤집으면 맨 앞의 부호 비트도 1이 되어 음수가 된다. 자바가 음수를 적는 방식에서는 “비트를 모두 뒤집고 1을 더한 것”이 부호를 바꾼 수다. 그래서 뒤집기만 한 것은 부호를 바꾼 수보다 1 작다. 식으로는 ~x가 늘 \(-x - 1\)이다1. 45를 넣으면 \(-45 - 1 = -46\)이다. 자세한 것은 정수의 2진 표현과 2의 보수에 있다.
피연산자는 정수 타입(byte, short, int, long)만 된다. float와 double은 안 된다. 1.5 & 1은 bad operand types for binary operator '&'라는 컴파일 오류다1. byte와 short는 타입 변환에서 본 대로 int로 바뀐 뒤 계산되므로 결과는 int다.
시프트 연산
비트 줄 전체를 왼쪽이나 오른쪽으로 민다.
| 식 | 하는 일 | 빈자리 |
|---|---|---|
a << b |
비트를 왼쪽으로 b칸 민다 |
오른쪽 빈자리를 0으로 채운다 |
a >> b |
비트를 오른쪽으로 b칸 민다 |
왼쪽 빈자리를 맨 앞 비트(부호 비트)와 같은 값으로 채운다 |
a >>> b |
비트를 오른쪽으로 b칸 민다 |
왼쪽 빈자리를 0으로 채운다 |
1 << 3을 따라가 본다. int는 32비트지만 아래 8비트만 그린다.
1 = ... 0 0 0 0 0 0 0 1
1 << 3 = ... 0 0 0 0 1 0 0 0 = 8
(왼쪽 끝에서 밀려난 3비트는 버리고, 오른쪽 빈 3칸은 0)
10진수에서 숫자를 왼쪽으로 한 칸 밀고 0을 붙이면 10배가 된다. 2진수에서는 2배다. 그래서 왼쪽으로 b칸 밀면 \(2^b\)를 곱한 것과 같다. \(1 \times 2^3 = 8\)이다.
오른쪽으로 미는 연산이 둘인 것은 음수 때문이다. −8을 오른쪽으로 한 칸 밀어 본다.
-8 = 1 1 1 1 ... 1 1 1 1 1 0 0 0
-8 >> 1 = 1 1 1 1 ... 1 1 1 1 1 1 0 0 = -4 (빈자리를 부호 비트 1로)
-8 >>> 1 = 0 1 1 1 ... 1 1 1 1 1 1 0 0 = 2147483644 (빈자리를 0으로)
>>는 부호를 지켜 준다. >>>는 부호 비트 자리에 0이 들어와 아주 큰 양수가 된다. 양수에서는 둘의 답이 같다.
>>로 b칸 미는 것은 \(2^b\)로 나누는 일과 거의 같다. 정확히는 a >> b가 \(a / 2^b\)를 작은 쪽으로 내린 값 \(\lfloor a / 2^b \rfloor\)이다(\(\lfloor \ \rfloor\)는 소수점 아래를 작은 쪽으로 내린다는 기호다). 자바의 /는 0 쪽으로 버린다. 그래서 a가 음수이고 \(2^b\)로 나누어떨어지지 않으면 둘이 다르다. -7 / 2는 −3.5를 0 쪽으로 버린 −3이고, -7 >> 1은 작은 쪽으로 내린 −4다. a가 0 이상이면 둘이 같다1.
예제
비트 하나를 켜고, 끄고, 확인하고, 뒤집기. 처음의 파일 권한을 만든다. 읽기를 0번 비트, 쓰기를 1번 비트, 실행을 2번 비트로 정한다2.
final int READ = 1 << 0; // 001 = 1
final int WRITE = 1 << 1; // 010 = 2
final int EXEC = 1 << 2; // 100 = 4
int flags = 0;
flags |= READ; // 켜기: 001
flags |= EXEC; // 켜기: 101 = 5
boolean canWrite = (flags & WRITE) != 0; // 확인: false
flags &= ~READ; // 끄기: 100 = 4
flags ^= WRITE; // 뒤집기: 110 = 6
| 하고 싶은 일 | 쓰는 식 | 까닭 |
|---|---|---|
| 켜기 | flags \| 비트 |
OR은 한쪽이 1이면 1이다. 다른 자리는 0과 OR이라 그대로다 |
| 끄기 | flags & ~비트 |
~비트는 그 자리만 0이다. AND라 그 자리만 0이 된다 |
| 확인 | (flags & 비트) != 0 |
그 자리만 남기고 다 지운 뒤 0인지 본다 |
| 뒤집기 | flags ^ 비트 |
XOR은 1과 만나면 뒤집히고 0과 만나면 그대로다 |
여러 비트를 묶어 꺼내기. 색을 0xRRGGBB처럼 정수 하나에 담았을 때 초록 성분만 꺼낸다. 초록은 가운데 8비트다. 오른쪽으로 8칸 밀어 맨 아래로 내린 뒤, 아래 8비트만 남긴다2.
int color = 0x3B82C4;
int green = (color >> 8) & 0xFF; // 0x82 = 130
경계 사례. 시프트도 넘친다. 1 << 31은 부호 비트 자리에 1이 들어가 int의 최솟값이 된다. int를 32칸 이상 밀 때 자바는 칸 수를 32로 나눈 나머지만 쓴다. 그래서 1 << 32는 0이 아니라 1이다. long으로 밀려면 1L << 32로 쓴다1.
활용
- 이 기법을 떠올릴 신호: 예/아니오 여러 개를 한 값에 담는다, 값 하나에서 몇 비트만 떼어 낸다, 2의 거듭제곱을 곱하거나 나눈다.
- 짝수와 홀수는 맨 아래 비트로 가른다.
(n & 1) == 1이면 홀수다.n % 2 == 1은 음수 홀수에서false가 되지만(-7 % 2는 −1) 이 식은 음수에서도 맞다1. - 흔한 실수:
flags & WRITE == 0이라고 쓴다.==가&보다 먼저라flags & (WRITE == 0)이 되어 컴파일 오류다.(flags & WRITE) == 0처럼 괄호를 친다. - 흔한 실수:
byte에>>>를 쓴다.byte가int로 바뀔 때 앞이 부호 비트로 채워져 있어 기대와 다른 값이 나온다.byte b = -16일 때b >>> 4는 268,435,455다. 먼저b & 0xFF로 아래 8비트만 남기면(b & 0xFF) >>> 4는 15다1.
연결
- 선수: 연산자와 오버플로, 진법과 자릿수
- 음수의 비트 모양과 부호 비트: 정수의 2진 표현과 2의 보수
- AND, OR, XOR, NOT을 회로로 만든 것: 불 대수와 논리 회로
과목별 관점
어셈블리어 (2-1학기). Java의 비트 연산자 일곱 개는 x86 CPU의 명령 하나씩과 짝이 맞는다3.
| Java | x86 명령 | 빈자리를 채우는 것 |
|---|---|---|
&, \|, ^, ~ |
AND, OR, XOR, NOT |
|
<< |
SHL (SAL도 같은 명령이다) |
오른쪽을 0으로 |
>> |
SAR |
왼쪽을 부호 비트로 |
>>> |
SHR |
왼쪽을 0으로 |
빈자리를 0으로 채우는 밀기를 논리 시프트, 부호 비트를 베껴 채우는 밀기를 산술 시프트라 부른다. 산술 시프트는 수의 부호를 지킨다. 예를 들어 mov dl,-80 뒤에 sar dl,1을 하면 −40, 이어서 sar dl,2를 하면 −10이다. mov dl,80 뒤의 shr dl,1은 40이다. 자세한 것은 시프트 명령과 논리 시프트와 산술 시프트 비교에 있다.
어셈블리어 쪽에만 있는 것이 둘 있다.
- 밀려 나간 비트가 남는다. Java에서는 밖으로 밀려난 비트가 그냥 사라진다. CPU에서는 마지막으로 밀려난 비트가 올림 플래그 CF에 들어간다.
- 결과가 0인지가 따로 남는다.
AND,OR,XOR은 결과가 0이면 ZF라는 표시를 켠다. 그래서 위의(flags & WRITE) != 0을 어셈블리어로는 값을 바꾸지 않고 AND만 해 보는test한 줄과 “0이 아니면 가라”는jnz한 줄로 쓴다. 명령마다의 쓰임은 비트 논리 명령과 TEST와 CMP에 있다.
위 경계 사례의 “int를 32칸 이상 밀면 칸 수를 32로 나눈 나머지만 쓴다”도 CPU에서 온 것이다. x86의 시프트 명령은 32비트 값을 밀 때 칸 수의 아래 5비트만 본다3.
확인 문제
C1 12 & 10, 12 | 10, 12 ^ 10, ~12의 값을 2진수로 풀어 구하라.
답: 12 = 1100, 10 = 1010. &는 1000 = 8, |는 1110 = 14, ^는 0110 = 6, ~12는 −13.
이유: ~x는 \(-x - 1\)이다. ~12를 0011 = 3이라고 쓰는 오답이 많다. int는 32비트라 앞의 0 스물여덟 개도 모두 1로 뒤집힌다.
C2 오른쪽 시프트는 왜 >>와 >>> 둘인가? -16 >> 2와 -16 >>> 28의 값으로 설명하라.
답: 밀고 난 왼쪽 빈자리를 채우는 방법이 둘이기 때문이다. >>는 부호 비트로 채워 부호를 지킨다. -16 >> 2는 −4다. >>>는 0으로 채워 비트 줄을 그대로 옮긴다. -16 >>> 28은 맨 위 4비트 1111만 남아 15다.
이유: 수의 크기를 반으로 줄이려는 때는 >>, 비트 묶음을 꺼내려는 때는 >>>를 쓴다. 양수에서는 둘이 같다.
C3 “a >> 1은 a / 2와 같다”가 틀리는 a를 하나 들고 두 값을 쓰라. 어떤 a에서 틀리는가?
답: a = -7. -7 >> 1은 −4, -7 / 2는 −3.
이유: /는 0 쪽으로 버리고(−3.5 → −3) >>는 작은 쪽으로 내린다(−3.5 → −4). 음수이면서 홀수일 때만 다르다. a가 0 이상이거나 짝수면 같다.
C4 (1 << 4) - 1의 값과 비트 모양을 쓰고, 정수 n의 아래 4비트만 꺼내는 식을 쓰라.
답: 15, 1111. n & ((1 << 4) - 1) 또는 n & 0xF.
이유: 1 << 4는 10000이고 1을 빼면 아래 4자리가 모두 1이 된다. 이 값과 AND 하면 아래 4비트만 남고 나머지는 0이 된다.
C5 Java의 x >> 2와 x >>> 2를 x86 명령으로 옮기면 각각 무엇인가? 8비트 레지스터 AL에 F0h(부호 있게 읽으면 −16)가 있을 때 두 명령의 결과를 16진수로 쓰라.
답: >>는 sar al,2, >>>는 shr al,2. SAR의 결과는 FCh(−4), SHR의 결과는 3Ch(60).
이유: F0h는 1111 0000이다. SAR은 왼쪽 빈자리 둘을 부호 비트 1로 채워 1111 1100, SHR은 0으로 채워 0011 1100이 된다. 부호를 지키려면 SAR, 비트 줄을 그대로 옮기려면 SHR이다3.
-
값은 구현 코드로, 컴파일 오류 메시지는 JDK 21에서 직접 컴파일해 확인했다. 규칙은 Java Language Specification §15.19(시프트), §15.22(비트 논리)에 있다. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
표의 짝은 −16, −8, −7, −1, 0, 1, 7, 45,
int의 최댓값·최솟값을 0~31칸 밀어 Java의 정의(Java Language Specification §15.19)와 x86 명령의 흉내가 같은 값을 내는지 29_bitwise-instructions_verify.py로 확인했다(파이썬 흉내. 실제 CPU 명령을 돌린 것이 아니다). 칸 수를 5비트로 자르는 것은 Intel 64 and IA-32 Architectures Software Developer’s Manual 2권의 SAL/SAR/SHL/SHR 항목에 있다. ↩ ↩2 ↩3