锁在并发编程中可以说是经常使用的一种资源保护技术,随着计算机科学和项目工程的发展,锁技术同样在发展和优化,从单体锁到分布式锁,本文介绍 Java 基础锁的相关知识。
volatile
volatile 是 Java 的关键字,只能用来修饰变量无法修饰方法和代码块等,通常被比喻成“轻量级的 synchronized”,但它并不是真正的锁。如果一个变量可能被多线程同时访问,只需要在变量声明时使用 volatile 修饰即可。volatile 在线程安全的方面只能保证有序性和可见性,并不能保证原子性,因为它不是锁,没有做任何可以保证原子性的处理。
- 保证可见性:使用 volatile 关键字修饰的变量只要有一个线程将主内存中的变量值做了修改,其他线程都会马上收到通知,立即从主内存中重新读取获得最新值。
1 | public class Test { |
保证有序性:在编译时禁止编译器对指令进行重排序,运行时禁止处理器对指令进行重排序。单线程环境下,可以确保程序最终执行结果和代码顺序执行结果的一致性,不论是否重排都不会出错。多线程环境中,线程交替执行。由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果也就无法预测。而用volatile关键字修饰的变量,可以禁止指令重排序,从而避免多线程环境下,程序出现乱序执行的现象。
不保证原子性:原子性的特点是要么不执行,一旦执行就必须全部执行完毕,volatile 并不能保证原子性,执行过程中是可以被其他线程打断甚至是其他高优先级的线程优先执行。对于单个volatile变量的读和写操作都具有原子性,但类似于
int volatile++
这种复合操作不具有原子性。所以volatile的原子性是受限制的,在多线程环境中并不能保证原子性。
1 | public class Test { |
除了极端情况下,此时输出的结果大概率不是1000,因为部分数据在累加的过程中被覆盖丢失了。如果要保证原子性,可以使用 synchronized
加锁或者使用 Atomic
类来包装变量。
synchronized
synchronized 是 Java 的关键字,可用于修饰方法和代码块用来加锁,最终都说根据对象来进行锁定。根据其锁定的对象不同,可以用来定义同步方法和同步代码块。其添加的锁具有互斥性、阻塞性、可重入性,但 synchronized 无法实现公平锁。
synchronized特性
- 互斥性:同一时间只有一个线程可以获得锁,获得锁的线程才可以执行被 synchronized 修饰的代码片段。
- 阻塞性:只有获得锁的线程才可以执行代码片段,未获得锁的线程会阻塞等待锁释放。
- 可重入性:如果一个线程已获得锁,在未释放之前再次请求还可以再获得锁(可用于递归)。
由于锁的互斥性、阻塞性限制了同一时间只有一个线程可以获得锁执行,单线程执行的过程中由于指令的重排序有一定限制,所有操作都是有序的,所以 synchronized 可以保证有序性。
由于 synchronized 在开始执行时加锁,执行完成后解锁,在变量解锁之前将变量同步回主存中,解锁后其他线程就读取到被修改后的变量值,所以 synchronized 可以保证可见性。
1 | // 同步方法 |
synchronized修饰范围
在使用 synchronized 锁时可用于修饰方法和代码块用来加锁,但应谨慎选择锁定的方法和对象,锁定普通方法、静态方法和锁定 this 实例对象、 class 类对象都会得到不同的执行结果。
使用 synchronized 修饰普通方法和静态方法将会影响代码执行的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public class ThreadTest {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
}
}
public static class MyThread implements Runnable {
public void run() {
print();
}
// 这里的print是普通方法
public synchronized void print() {
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + System.currentTimeMillis());
}
}
}输出结果如下:
1
2
3
4
5
6
7
8
9
10Thread-1:1606026057314
Thread-4:1606026057314
Thread-0:1606026057314
Thread-8:1606026057314
Thread-3:1606026057314
Thread-7:1606026057314
Thread-6:1606026057314
Thread-2:1606026057314
Thread-5:1606026057314
Thread-9:1606026057314可以看到他们是在同一时间被执行的,多个线程之间没有互相被锁阻塞影响。但如果是静态方法输出结果就不一样了。
1
2
3
4
5
6
7
8
9// 将print方法使用static修饰为静态方法
public static synchronized void print() {
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + System.currentTimeMillis());
}此时输出结果如下:
1
2
3
4
5
6
7
8
9
10Thread-0:1606026050301
Thread-9:1606026051316
Thread-8:1606026052321
Thread-6:1606026053323
Thread-7:1606026054337
Thread-4:1606026055338
Thread-5:1606026056353
Thread-3:1606026057353
Thread-2:1606026058360
Thread-1:1606026059361可以发现此次代码在执行时,是顺序并且有一定的延迟输出的,每个线程输出的时间戳都具有一定的时间间隔,这是因为线程之间有锁阻塞的影响导致的。
使用 synchronized 修饰代码块时选择锁定的对象也会影响代码执行的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class ThreadCodeTest {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
}
}
public static class MyThread implements Runnable {
public void run() {
synchronized (this) {
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ":" + System.currentTimeMillis());
}
}
}此时输出的结果如下,多个线程之间并没有互相被锁阻塞:
1
2
3
4
5
6
7
8
9
10Thread-1:1606026057417
Thread-4:1606026057417
Thread-0:1606026057417
Thread-8:1606026057417
Thread-3:1606026057417
Thread-7:1606026057417
Thread-6:1606026057417
Thread-2:1606026057417
Thread-5:1606026057417
Thread-9:1606026057417将锁定对象改为具体的类,再次执行。
1
2
3
4
5
6
7
8
9
10
11
12
13public static class MyThread implements Runnable {
public void run() {
synchronized (MyThread.class) {
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ":" + System.currentTimeMillis());
}
}此时输出结果如下,多个线程之间受锁阻塞的影响:
1
2
3
4
5
6
7
8
9
10Thread-0:1606026066163
Thread-9:1606026067176
Thread-8:1606026068180
Thread-7:1606026069191
Thread-6:1606026070194
Thread-5:1606026071207
Thread-4:1606026072222
Thread-3:1606026073237
Thread-2:1606026074249
Thread-1:1606026075264
synchronized锁升级
synchronized 锁有不同的状态,用来适应不同场景下的锁竞争情况。
- 偏向锁:偏向锁在JDK15中被废弃。当synchronized块首次进入时,锁对象会进入偏向模式,锁偏向于第一个获取它的线程,JVM 会在对象头中记录该线程ID作为偏向锁的持有者。在这种情况下,其他线程访问该对象会先检查偏向锁标志,如果和标记的线程ID相同则直接获取锁,不同则升级到轻量锁状态。
- 轻量级锁:在轻量级锁状态下,JVM为对象头的 Mark Word 预留了一部分空间,用于存储指向线程栈中锁记录的指针。当一个线程尝试获取轻量级锁时,JVM会在当前线程的栈帧中创建锁记录空间,然后将对象投中的 Mark Word 复制到这个锁记录中。接下来,JVM 尝试使用 CAS(Compare And Swap)操作将对象他的 Mark Word 更新为指向锁记录的指针,如果更新成功则获取到锁。如果失败则表示已经有其他线程获取了锁,接下来就会升级到重量级锁状态。
- 重量级锁:synchronized 锁是通过对象内部的监视器锁来实现的,当线程请求对象锁时,如果对象没有被锁,线程就会获取锁并执行。如果对象已经被锁住,直到锁释放前线程都会被阻塞,在对象他中记录指向等待列表的指针,当锁被释放时,JVM 会从等待列表中选择一个线程唤醒,将该线程状态设置为“就绪”,等待该线程重新获取该对象的锁。因为获取锁和释放锁都需要在操作系统层面进行线程的阻塞和唤醒,这些操作带来的开销较大,因此这种锁实现方式称为“重量级锁”。
ReentrantLock
ReentrantLock 在 java.util.concurrent
包中实现,由AQS所实现,需要手动获取、释放锁,可以响应中断、设置等待超时时间,避免无期限的等待锁的获取,造成系统阻塞,线程的阻塞与唤醒采用自旋进行管理,所有操作均在用户空间进行,但是如果自旋时间过长同样会降低系统吞吐量。同时它还是可重入的,可以实现公平锁/非公平锁。
如果处理器为单核,不推荐使用ReentrantLock,因为在单核情况下,自旋会耗尽CPU为其分配的时间片,白白浪费资源且会造成大量的线程阻塞。
1 | public class LockTest { |
什么是公平锁
当多个线程尝试获取同一个对象的锁时,并且此时该对象的锁还未释放,公平锁则保证等待时间最久的线程将会获得该锁。而非公平锁则是让多个线程继续随机争抢,没有先来后到无法保证某个等待最久的线程一定能获取到锁。
公平在现实生活中是一件好事,然而维护公平却需要更多的开销。公平锁会增加一定的上下文切换次数,适用于对线程获取锁的顺序有严格要求的场景,而非公平锁适用于追求更高吞吐量的场景。
ReentrantLock 默认为非公平锁,需要初始化构造函数传入 true
参数: ReentrantLock fairLock = new ReentrantLock(true);