一个问题的思考
建设我们有两个线程,一个进行5000次的相加操作,另一个进行5000次的减操作。那么最终结果是多少
package com.jia.syn;
import java.util.concurrent.TimeUnit;
/**
* @author qxlx
* @date 2024/1/2 10:08 PM
*/
public class SynTest {
private Integer tickets = 0;
public void sell() {
tickets++;
}
public void sell2() {
tickets--;
}
public static void main(String[] args) throws InterruptedException {
SynTest synTest = new SynTest();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synTest.sell();
}
});
Thread thread = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synTest.sell2();
}
});
thread1.start();
thread.start();
thread.join();
thread1.join();
TimeUnit.SECONDS.sleep(3);
System.out.println("总共卖出多少票" + synTest.tickets);
}
}
执行上述代码之后,发现结果却不是0,为什么
7 getfield #3 <com/jia/syn/SynTest.tickets : Ljava/lang/Integer;>
10 invokevirtual #4 <java/lang/Integer.intValue : ()I>
13 iconst_1
14 iadd
15 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
18 dup_x1
通过分析字节码变量,可以看出其实i++, i–操作其实是三个步骤,也就是先获取i的值,对i+1操作,然后在对i赋值。那么这样就可以解释为什么执行的最终结果不是期望的0值。
程序在执行的时候,不同的两个线程执行。比如会出现线程2获取到i的值是10,对i-1操作9,但是想要将i=9赋值操作的时候,发现CPU执行权被线程1获取,此时线程1获取到i的值是10,对i+1操作,然后复制给i=11。但是紧接着就是线程2对i=9赋值。所以最终出现的结果就是9,而不是 原来的11。将线程1的值进行覆盖更新了。
临界区
上述其实是多个线程对于共享资源进行读写操作,导致出现数据不一致。如果是只读,那没有问题,但是有写操作,就会出现乱序问题。
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源
上述中的
public void sell() { // 临界区
tickets++;
}
public void sell2() { //临界区
tickets--;
}
为了解决上述的问题,那么可以使用多种手段进行解决。
- 阻塞式:synchronized、lock
- 非阻塞式:原子变量
Synchronized
private Object obj = new Object();
// 方法
//静态方法-锁住的类对象
public static synchronized void test1() {
}
//普通方法-锁住的对象实例
public synchronized void test2(){
}
//代码块
public void test3 (){
//代码块-锁住的是该类对象
synchronized (SynTest2.class) {
}
}
//代码块
public void test4 (){
//代码块-锁住的是该对象实例
synchronized (this) {
}
}
//代码块
public void test5 (){
//代码块-锁住的是该obj对象实例
synchronized (obj) {
}
}
所以解决上述的问题,就可以加syn锁。
原理
synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语
Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的 优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操 作的开销,内置锁的并发性能已经基本与Lock持平。
同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥 原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态 之间来回切换,对性能有较大影响。
同步方法
同步代码块
0 aload_0
1 dup
2 astore_1
3 monitorenter //进入
4 aload_1
5 monitorexit //退出
6 goto 14 (+8)
9 astore_2
10 aload_1
11 monitorexit //异常退出
12 aload_2
13 athrow
14 return
管程
管程,其实是管理共享变量以及对共享变量操作过程。英文是Monitor,也叫监视器。
管程有三种不同的管程模型,Hasen模型、Hoare模型、MESA模型。目前主要用的后者。
并发编程中,互斥解决的是对于共享资源同时只能有一个线程访问,同步是线程之间如何通信、协作的问题。管程都可以解决。
条件变量等待队列解决的是同步问题,入口等待队列解决的是互斥问题。
java中对管程的实现进行了精简,只有一个条件变量等待队列。
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖
于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制。 ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp)
ObjectMonitor(){
2 _header = NULL; //对象头 markOop
3 _count = 0;
4 _waiters = 0,
5 _recursions = 0; // 锁的重入次数
6 _object = NULL; //存储锁对象
7 _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
8 _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
9 _WaitSetLock = 0 ;
10 _Responsible = NULL ;
11 _succ = NULL ;
12 _cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
13 FreeNext = NULL ;
14 _EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失
败的线程)
15 _SpinFreq = 0 ;
16 _SpinClock = 0 ;
17 OwnerIsThread = 0 ;
18 _previous_owner_tid = 0;
19 }
我们主要关注的其实是waitSet,cxq 、EntryList。
1.多个线程竞争获取锁
多个线程同时请求获取Monitor锁时,会通过CAS操作,设置_owner字段。谁设置成功,就获取锁。
2.没有获取锁的线程排队等待获取锁
多个线程获取锁,获取到锁的线程就去执行任务,没有获取到锁的线程会进入到_cxq队列中等待获取锁。
3.获取到锁之后通知排队等待锁的线程去竞争锁
当执行完线程的释放锁的时候,会从_EntryLitst取出一个线程,去通过CAS竞争锁,之所以不让这个线程获取锁而去竞争锁,是因为同时可能有别的线程可能获取到锁。
如果_EntryList队列为空的话,那么将_cxq所有线程全部搬移到_EntryList中。在中_EntryList中获取线程。
另外就是当调用 Object.wait() 会进入 _WaitSet 队列,只要被唤醒时,才会重新进入 EntryList 中去增强锁。
总结
本篇主要通过一个案例讲解了线程安全问题,以及介绍了syn代码块和方法底层实现的区别,以及介绍了管程、java中实现管程的方式。下一篇文章,开始介绍syn的锁升级。