所谓的共享问题就是对于多线程来说,可能存在有多个线程共享进程的资源的时候,由于分时系统,时间切片导致的资源问题。
看下面一个案例:
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ThreadProblem01 {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.info("{}",counter);
}
}
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
而对应 i-- 也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题。但是由于分时系统,多线程下这 8 行代码可能交错运行。
比如第一个线程获取到i变量的值0后,进行++操作,把变量i改为1了,但是还没有把变量的值写入到静态变量,这时候,如果时间片用完,这个线程阻塞,把时间片分给另一个线程了,另一个线程获取到i变量的值还是从内存里获取,还是0,然后再–操作,结果为-1,然后写到静态变量里,这时候第一个线程又获取时间片了,又把变量改为1了。
这样就导致最终的结果不对。
基于上述问题,有以下两个概念。
1.临界区 Critical Section
一个程序运行多个线程本身是没有问题的问题出在多个线程访问共享资源。多个线程读共享资源其实也没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题。
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。
2.竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
本次课使用阻塞式的解决方案:使用synchronized
加锁,来解决上述问题。
synchronized俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
一般语法如下:
synchronized(对象)
{
临界区
}
当线程执行到临界区的代码,会尝试获取对象锁,获取到锁,其他线程执行到会阻塞运行。直到第一个线程释放锁,其他线程才可以获取到锁,才能执行临界区代码。
修改我们上面共享问题的案例,如下:
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SynchronizedTest01 {
static int counter = 0;
// 锁需要的对象可以是任意对象,这里我们随便创建一个对象即可
static final Object ROOM = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (ROOM) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (ROOM) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.info("{}",counter);
}
}
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
如果以面向对象来改进上面的代码,还可以写成如下:
import lombok.extern.slf4j.Slf4j;
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
@Slf4j
public class SynchronizedTest02 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.info("count: {}" , room.get());
}
}
注意不加 synchronzied 的方法不需要获取锁,不会受到锁的阻塞。
synchronized可以加在成员方法上,则相当于锁住了this
对象。
修改上面Room类的代码如下:
class Room {
int value = 0;
public synchronized void increment() {
value++;
}
public synchronized void decrement() {
value--;
}
public synchronized int get() {
return value;
}
}
synchronized也可以加在static方法上,则相当于锁住了class
对象。
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
如果它们没有共享,则线程安全
如果它们被共享了,根据它们的状态是否能够改变,又分两种情况:
如果只有读操作,则线程安全;如果有读写操作,则这段代码是临界区,需要考虑线程安全。
局部变量是线程安全的
但局部变量引用的对象则未必:
如果该对象没有逃离方法的作用访问,它是线程安全的;如果该对象逃离方法的作用范围,需要考虑线程安全。
如下:
public static void test1() {
int i = 10;
i++;
}
操作i变量时,每个线程有自己栈帧,多个线程的i不会存在线程安全问题。
但是局部变量的引用稍有不同
import java.util.ArrayList;
public class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2();
method3();
// } 临界区
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量,可能两个线程同时add时,只add进一个。method3 与 method2 相同。
下面将 list 修改为局部变量:
import java.util.ArrayList;
public class Threadsafe {
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2(list);
method3(list);
// } 临界区
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
list.remove(0);
}
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
Threadsafe test = new Threadsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
list 是局部变量,每个线程调用时会创建其不同实例,没有共享;而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象。不会存在线程安全问题。
方法访问修饰符带来的思考:
如果把 method2 和 method3 的方法修改为 public 会不会存在线程安全问题?
如下ThreadSafeSubClass继承可我们的ThreadSafe,并且重写了方法method3,改为了public修饰符:
import java.util.ArrayList;
public class ThreadSafe {
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2(list);
method3(list);
// } 临界区
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadSafe test = new ThreadSafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
上面也是有线程安全问题的。因为上面的ThreadSafe的method1里的list传到method3里,但是ThreadSafeSubClass的对象的method3新开了一个线程,里面也操作了list,那么list可能同时被两个method3修改。这就是局部局部变量的引用暴漏给了其他线程造成线程不安全问题。
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。
它们的每个方法是原子的,但注意它们多个方法的组合不是原子的。
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
上面table对象是线程安全的,但是上面的代码同时调用了get和put方法,就不是线程安全的了。
在java中,一般我们的对象在内存中存储的布局可以分为三块区域:
mark word
和class word
组成。其中对象头主要包含mark word 和 class word(主要用来标记对象属于哪个类):
但是对于数组对象还要多32字节表示字节长度:
其中最复杂的是MarkWord。
Mark Word
用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。
在64位的虚拟机中中,MarkWord的信息如下:
在32位的虚拟机中中,MarkWord的信息如下:
这里先了解一下大概,MarkWord的组成和具体各种锁有很大关系。各种锁后面还会学。
Monitor
被翻译为监视器或管程。Monitor是由操作系统提供。
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级锁)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针。
Monitor 结构主要有三个:
owner
:存储目前拥有这个锁的对象的线程EntryList
:存储想要获取锁,但是正在排队的线程WaitSet
:存储以前获取到锁,但是处于wating状态的线程其原理如下图:
Monitor的使用过程如下:
注意:
下面分析下加了synchronized锁的代码的字节码有何变化。
以下面的同步代码块为例:
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
对应的字节码为:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 8: 0
line 9: 6
line 10: 14
line 11: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
其中重点看一下上面有注释的部分,最重要的是monitorenter
和monitorexit
字节码。
1、monitorenter:如果当前monitor的进入数为0时,线程就会进入monitor,并且把进入数+1,那么该线程就是monitor的拥有者(owner)。
2、如果该线程已经是monitor的拥有者,又重新进入,就会把进入数再次+1。也就是可重入的。
3、monitorexit,执行monitorexit的线程必须是monitor的拥有者,指令执行后,monitor的进入数减1,如果减1后进入数为0,则该线程会退出monitor。其他被阻塞的线程就可以尝试去获取monitor的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
总的来说,synchronized的底层原理是通过monitor对象来完成的。
如果是同步的成员方法,代码如下:
public synchronized void hello(){
System.out.println("hello world");
}
其字节码如下:
可以看到多了一个标志位ACC_SYNCHRONIZED,作用就是一旦执行到这个方法时,就会先判断是否有标志位,如果有这个标志位,就会先尝试获取monitor,获取成功才能执行方法,方法执行完成后再释放monitor。在方法执行期间,其他线程都无法获取同一个monitor。归根结底还是对monitor对象的争夺,只是同步方法是一种隐式的方式来实现。
补充:下面的知识中涉及了大量的CAS操作,后面会详细讲解,这里先理解为一个“比较并设置”的步骤,需要一定的时间,能保证整个步骤的原子性。
前面讲的synchronized上锁是重量级锁。只是上锁的一种,其实在底层还有很多锁的种类,来满足各种需求:
结合上面的MarkWord的信息,可总结四种锁状态信息以及特点如下:
锁状态 | 存储内容 | 锁标志位 |
---|---|---|
无锁 | 对象的hashCode、分代年龄、是否偏向锁(0) | 01 |
偏向锁 | 线程ID、偏向时间戳、对象分代年龄、是否偏向锁(1) | 01 |
轻量级锁 | 指向占中锁记录的指针 | 00 |
重量级锁 | 指向重量级锁(monitor)的指针 | 10 |
其中锁升级的过程是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
下面一一来看下各个状态的锁如何产生及转换。
无锁也就是没有加任何锁。
synchronized锁的object对象头部markword区域,参考上面java对象头存储的信息图,最开始锁状态标志位就是0 01,第一个0代表不是偏向锁。
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized。也就是说使用者不用做任何事,直接使用synchronized,会优先使用轻量级锁,如果使用失败,再使用重量级锁。
假设有两个方法同步块,利用同一个对象加锁,如下:
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
其上锁解锁过程如下:
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构(Lock Record),内部可以存储锁定对象的引用(左图最下面黄色块)和对象的Mark Word(左图最上面黄色块)
让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
重量级锁竞争的时候,还可以使用自旋来进行优化(一般用循环来实现):即当前线程会自旋一段时间,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以获取锁,从而避免被阻塞。
注意:
轻量级锁有个缺点:在没有竞争时(就一个线程使用锁),每次重入(同一个线程内多次获取同一个锁)仍然需要执行 CAS 操作。这样是非常耗时的操作。
如下示例:在m1所在线程的同步块里又调用了m2方法,有进入m2的同步块,同时调用m3,又进入m3的同步块
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
// 同步块 C
}
}
Java 6 中引入了偏向锁来做进一步优化。
偏向锁升级过程如下:
偏向锁撤销过程:
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。
偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识:
整个过程如下如所示:
一般只有一个线程使用锁比较适合偏向锁。如果应用程序里所有的锁通常出于竞争状态,那么偏向锁就会是一种累赘,Java15后逐步废除偏向锁。可以一开始就把偏向锁这个默认功能给关闭:运行时在添加 VM 参数 -XX:-UseBiasedLocking
可禁用偏向锁。
回忆下前面说的对象头的mark word信息。一个对象创建时:
-XX:BiasedLockingStartupDelay=0
来禁用延迟注意:
当处于偏向锁的对象解锁后,对象头中的线程 ID 不会立即清除。相反,JVM 会延迟清除线程 ID。这样做是为了提高性能,在短时间内再次加锁时可以利用已经存在的偏向锁,而不需要重新竞争锁。
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被
撤销。
为什么轻量级锁和重量级锁调用了对象的 hashCode不会影响锁状态?
因为偏向锁的的线程id是存在对象头里,当偏向锁调用了 hashCode,占用了对象头的31位,没有足够的空间存储线程id信息了。但是轻量级锁的线程id信息存储在线程的栈帧里,重量级锁存储在monitor中,不受影响。
有偏向锁就使用偏向锁,没有就优先使用轻量级锁,当有线程竞争锁,就会转为重量级锁。
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。