并发编程带来的挑战之同步锁
文章目录
以下是学习中的一些笔记记录,如果有错误希望得到你的指正~ 同时也希望对你有点帮助~
如果多个线程在做同一个事情的时候,可能发生线程安全性问题
- 原子性 Synchronized、AtomicXXX、Lock
- 可见性 Synchronized、Volatile
- 有序性 Synchronized、Volatile
原子性问题
在下面这个例子中,有两个线程,每个线程对变量进行自增 10000次
/**
* 线程的原子性
*
* @author zdp
*/
public class ThreadAtomicityClass {
int var = 0;
public void incr() {
var++;
}
public static void main(String[] args) {
ThreadAtomicityClass demo = new ThreadAtomicityClass();
Thread[] threads = new Thread[2];
for (int j = 0; j < 2; j++) {
threads[j] = new Thread(() -> {
// 创建两个线程
for (int k = 0; k < 10000; k++) {
// 每个线程跑10000次
demo.incr();
}
});
threads[j].start();
}
try {
threads[0].join();
threads[1].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(demo.var);
}
}
结果最后输出却小于 20000,这是因为在inc() 方法中 var++ 这个操作不是原子性的,这是因为 var++ 是属于Java高级语言中的编程指令,而这些指令 最终可能会有多条CPU指令来组成,而var++ 最终会生成3条指令,通过
javap -v ThreadAtomicityClass.class
可以查看到 inc() 方法的字节码指令如下:
public void incr();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // 访问变量 var Field var:I
5: iconst_1 // 将整型常量 1 放入操作数栈
6: iadd // 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
7: putfield #2 // 访问类字段(类变量),复制给ThreadAtomicityClass.var 这个变量
10: return
LineNumberTable:
line 13: 0
line 14: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/zdp/thread/thread_safety/ThreadAtomicityClass;
var++ 这个操作,如果要满足原子性,那么就需要保证某个线程在执行这个指令时,不允许其他线程干扰,现在我们没有做其他限制操作,实际上会导致这个原子性的问题发生。
一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前,对于var++这三个CPU指令来说,如果线程A在执行指令 var = 0 之后执行了线程切换,这时切换到了线程B,线程B同样执行 var = 0 的操作,紧接着执行 var++,最终整个代码执行完,就会导致最终结果 是1,而不是 2。
这就是在多线程环境下,存在的原子性问题。从上面图中表面上看是多个线程对于同一个变量的操作,实际上是var++这行代码,它不是原子的,所以才导致在多线程环境出现这样的问题。也就是说我们只需要保证,var++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可以解决问题。通过AutomicXXX、Lock、Synchroized
都可以解决原子性问题,下面引入Synchroized
同步锁来解决
/**
* 线程的原子性
*
* @author zdp
*/
public class ThreadAtomicityClass {
//=======================synchronized start============================
int var = 0;
public void incr() {
synchronized (this) {
var++;
}
}
//=======================synchronized end==============================
//=======================Lock start============================
//private final Lock lock = new ReentrantLock();
//
//public void incr() {
// lock.lock();
// var++;
// lock.unlock();
//}
//=======================Lock end==============================
//=======================AtomicXXX start============================
//public final AtomicInteger var = new AtomicInteger(0);
//
//public void incr() {
// //var.addAndGet(1);
// //var.incrementAndGet();
// var.getAndIncrement();
//}
//=======================AtomicXXX end==============================
public static void main(String[] args) {
ThreadAtomicityClass demo = new ThreadAtomicityClass();
Thread[] threads = new Thread[2];
for (int j = 0; j < 2; j++) {
threads[j] = new Thread(() -> {
// 创建两个线程
for (int k = 0; k < 10000; k++) {
// 每个线程跑10000次
demo.incr();
}
});
threads[j].start();
}
try {
threads[0].join();
threads[1].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(demo.var);
}
}
同步锁Synchronized
synchronized作用范围
- 修饰实例方法
- 静态方法
- 修饰代码块
可以控制锁的作用范围
抢占锁的本质是什么?
就是互斥,如何实现互斥?
- 得有共享资源
- 互斥的条件可以是一个标记 0 表示无锁 1表示 有锁
对象的作用域表示了锁的作用域,既然synchronized是通过对象来控制锁的,那么锁相关的信息(锁标记等信息)一定存储在这个对象中
synchronized(ThreadAtomicityClass){}
这就引出了MarkWord对象头
MarkWord对象头
对象头,简单理解就是一个对象在JVM内存中的布局或者存储的形式。在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充 4个字节(Padding)
mark-word: 对象标记字段占4个字节,用于存储一些列的标记位,比如哈希值,轻量级锁的标记位,偏向锁标记位、分代年龄等信息
Klass Pointer: Class对象的类型指针,Jdk8默认开启指针压缩后为4个字节,关闭指针压缩 -xx:-UseCompressedOops 后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址
实例数据: 包括对象的所有成员变量,大小由各个成员变量决定,比如: byte 占1个字节8比特位,int占4个字节 32比特位
对齐填充: 最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理系统要求对象其实地址必须是8字节的整数倍,所以对象头正好是8字节的倍数。因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全
- 在64位的虚拟机中,要求对象占用的字节数必须是8的倍数,如果不是8的倍数,将填充成8的倍数
- 对齐填充是为了读取数据的性能问题
ClassLayout打印对象的内存布局
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
public class LayoutClassTest {
public static void main(String[] args) {
//构建一个实例
LayoutClassTest obj = new LayoutClassTest();
//打印对象内存布局信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
OFFSET SIZE TYPE DESCRIPTION VALUE
// 存储对象头,在第一个字节的后三位,会存储锁相关的信息,倒数第三位是偏向锁标志位
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
// 存储Kalss Pointer类型指针
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
//对齐填充,保证对象占用字节数是8的倍数
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Synchronized锁升级
在Jdk1.6之前,只有无锁和重量级锁的概念,Jdk1.6引入了偏向锁和轻量级锁
-
无锁
-
偏向锁 (默认延迟开启)
- 假设A线程进入同步代码块,没有线程竞争的情况下,会偏向A线程(当前线程指针会指向的是偏向的线程 (线程A)),下一次A线程不需要去竞争锁就可以直接进入同步代码块
-
轻量级锁 (无锁化实现)
- 存在多个线程竞争的情况,当线程B想要进入同步代码块,发现锁已经偏向线程A,等到A线程到达一个全局的安全点 (所有指令执行完成),此时会升级为轻量级锁
- 在升级为轻量级锁之后,因为线程通常执行是很快的,B会通过自旋锁的方式(-xx: PreBlockSpin = ? 可以修改自旋次数)来尝试获得锁,通过自旋锁自旋重试是为了避免线程A、B阻塞唤醒带来更大的开销
- 在自旋(1.6优化为自适应自旋 )的代码中去不断修改lock_flag的标记,使用CAS来完成
- 在尝试重试自旋一定次数之后,仍无法抢占到锁,那么会升级为重量级锁,B线程会阻塞,将阻塞线程B添加到阻塞队列,这个添加到阻塞队列的动作是由ObjectMonitor对象监视器来完成的,阻塞逻辑在Monitor中实现
- 轻量级锁避免线程阻塞
- 存在多个线程竞争的情况,当线程B想要进入同步代码块,发现锁已经偏向线程A,等到A线程到达一个全局的安全点 (所有指令执行完成),此时会升级为轻量级锁
-
重量级锁
-
重量级锁通过LockSupport.park() 来进行阻塞,涉及用户态到内核态 (需要内核发送指令来完成的动作) 的交换
-
没有获得锁的线程会被阻塞,获得锁之后再被唤醒
-
重量级锁对性能消耗比较大
-
不同锁状态下的对象头信息
锁状态 | 偏向锁标记位 | 锁标记位 | 锁标记位 |
---|---|---|---|
无锁 | 0 | 0 | 1 |
偏向锁 | 1 | 0 | 1 |
轻量级锁 | 0 | 0 | |
重量级锁 | 1 | 0 |
轻量级锁
public class LightweightLockClass {
final static Object lock = new Object();
public static void main(String[] args) {
System.out.println("未加锁前的MarkWord对象头信息................");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
System.out.println("加锁后的MarkWord对象头信息................");
synchronized (lock){
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
//未加锁前的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000/**[001]*/ 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
//加锁后的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 70 f6 4a 03 (01110/**[000]*/ 11110110 01001010 00000011) (55244400)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
上面加锁后直接是轻量级锁而不是偏向锁是因为默认情况下偏向锁是延迟开启的,延迟开启是因为当前程序启动的时候会有一些默认线程的启动,这些线程中会有同步代码,这些线程会造成锁的竞争,可以用-XX:BiasedLockingStartupDelay=0
来关闭偏向锁的延迟开启,下面是关闭延迟开启的对象头信息:
//未加锁前的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000/**[101]*/ 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
//加锁后的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 58 9e 02 (00000/**[101]*/ 01011000 10011110 00000010) (43931653)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
这里加锁前后都是偏向锁是因为偏向锁打开状态下,默认会有配置匿名的对象获得偏向锁
重量级锁
public class HeavyLockClass {
final static Object lock = new Object();
public static void main(String[] args) {
System.out.println("未加锁前的MarkWord对象头信息................");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
new Thread(() -> {
synchronized (lock){
System.out.println("子线程 加锁后的MarkWord对象头信息................");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}).start();
synchronized (lock){
System.out.println("mian 线程 加锁后的MarkWord对象头信息................");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
//未加锁前的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000/**[001]*/ 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
//mian 线程 加锁后的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) aa 63 66 1c (10101/**[010]*/ 01100011 01100110 00011100) (476472234)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
//子线程 加锁后的MarkWord对象头信息................
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) aa 63 66 1c (10101/**[010]*/ 01100011 01100110 00011100) (476472234)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
由于Main线程对lock锁的竞争,导致锁升级为重量级锁
CAS (CompareAndSwap)
在锁涉及的相关操作中,以下操作必须是原子的:
- 修改锁的标记
- 修改线程指针的指向
CAS就是比较并交换的意思,它可以保证在多线程环境下对于一个变量修改的原子性
CAS包括三个值 原始值,预期值,更新值,如果原始值 == 预期值,那么就更新值
CAS 返回值
- true :修改成功
- false :修改失败
轻量级锁中修改锁标志位实现
//自旋锁
for(;;){
//(自旋次数控制避免一直修改失败)
if(CAS){
//修改锁标志位
break;
}
}
CAS 保证原子性
- 使用CPU层面的锁来控制,多核情况下会增加一个Lock指令 (缓存锁/总线锁)
CAS ABA问题
通过版本号解决