是什么?
在并发编程中线程安全问题是值得我们注意的,而造成线程安全问题主要是由于多线程操作共享数据导致的。其中一种解决方式就是加锁,当一个线程正在操作共享数据时,其他线程只能进入等待的状态,直到该线程处理完数据并且释放锁。在Java中,关键词Synchronized可以保证在同一时刻,只有一个线程可以进入方法或者代码块,对数据进行操作。并且Synchronized可以保证线程对于共享数据的操作,也就是共享数据的变化可以被其他的线程所感知,因此保证了可见性。
作用
保证了下面三个性质:
原子性
一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
可见性
多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的
有序性
程序执行的顺序按照代码先后执行
三大应用方式
synchronized关键字不能继承
作用在实例方法上
修饰实例方法,相当于给当前对象加锁,进入同步方法需要获取当前实例的锁
public class Test {
public synchronized void func() {
for (int i = 0; i < 10; i++) {
System.out.print(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
Thread t1 = new Thread() {
@Override
public void run() {
test.func();
}
};
Thread t2 = new Thread() {
@Override
public void run() {
test.func();
}
};
t1.start();
t2.start();
}
}
输出结果:01234567890123456789
注意:当一个线程正在访问一个同步的实例方法时,其他线程不能访问对象的其他同步方法 ,因为一个对象只有一把锁。但是其他线程可以访问该实例对象的其他非同步方法。
如果是一个线程 A 需要访问实例对象obj1的同方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象obj2 的同步方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同
Test test1 = new Test();
Test test2 = new Test();
Thread t1 = new Thread() {
@Override
public void run() {
test1.func();
}
};
Thread t2 = new Thread() {
@Override
public void run() {
test2.func();
}
};
输出结果:00112233445566778899
作用在静态方法上
修饰静态方法,相当于给当前类对象加锁,进入同步方法需要获取当前类对象的锁
给func函数加上static关键字
Thread t1 = new Thread() {
@Override
public void run() {
Test.func();
}
};
Thread t2 = new Thread() {
@Override
public void run() {
Test.func();
}
};
输出结果:01234567890123456789
作用在同步代码块上
修饰同步代码块,相当于给给定对象加锁,进入同步代码块需要获得给定对象的锁
public void func() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.print(i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
输出结果:01234567890123456789
底层实现
前置知识
由于Synchronized锁与对象息息相关,所以必须要了解一下对象的内部结构
在JVM中,对象在内存中主要分为三块区域:对象头、实例数据、填充字节。
- 实例数据:存放类的属性数据信息,包括父类的属性信息
- 填充字节:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
- 对象头:
- Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容
- Klass Pointer:类型指针指向它的类元数据的指针。
考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间
对象头与Monitor
虽然 Java6之后对锁进行了锁升级的设计,但是我们暂时先放一边,先分析一下Synchronized锁在重量级锁状态,也就是Java6之前Synchronized的实现。
当处于重量级锁状态的时候,Mark Word中前62位存放的是指向互斥量(重量级锁)的指针,而指向重量级锁的指针就是所谓的同步监视器monitor。每一个对象都与一个monitor关联,而在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下:
ObjectMonitor() {
_count = 0; //记录个数
_owner = NULL;//持有锁的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
//....
}
ObjectMonitor包含两个重要的队列EntrySet和WaitSet,当多线程访问同步代码时,会进入EntrySet。当某个线程获取到锁,相当于获取到monitor时,则会进入到Owner(蓝色区域),Owner变量设置为当前线程同时计数器count+1。若线程调用了wait方法则会释放monitor,owner变为null,count-1,同时进入waitset队列等待被唤醒。如果当前线程执行完毕会释放锁并将count复位,以便其他线程可以获取到锁。
同步代码块
3: monitorenter//进入同步方法
4: iconst_0
5: istore_2
6: iload_2
7: bipush 10
9: if_icmpge 39
12: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
15: iload_2
16: invokevirtual #3 // Method java/io/PrintStream.print:(I)V
19: ldc2_w #4 // long 100l
22: invokestatic #6 // Method java/lang/Thread.sleep:(J)V
25: goto 33
28: astore_3
29: aload_3
30: invokevirtual #8 // Method java/lang/InterruptedException.printStackTrace:()V
33: iinc 2, 1
36: goto 6
39: aload_1
40: monitorexit//退出同步方法
通过编译javac和反编译javap,我们可以看出同步代码块的实现其实是依靠monitorenter和monitorexit两条指令。当执行monitorenter时,线程会尝试获取对象的monitor,并且计数器++,如果锁被其他线程所持有,那么线程将进入堵塞状态。当其他线程执行完毕,即monitorexit指令被执行,其他线程会释放锁并将计数器复位,其他线程才有机会去持有monitor。
同步方法
JVM通过方法常量池 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后再方法完成时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。
public synchronized void func();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: iload_1
补充
因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。而在Java6,对Synchronized引入了锁升级的概念,对锁机制进行优化。
锁升级
锁的四种状态:无锁,偏向锁,轻量级锁,重量级锁(依靠monitor实现)
无锁
资源不会被竞争,也就不需要加锁
偏向锁
在多线程的情况下,当一个线程获取到锁时,锁就进入偏向锁的状态,Mark Word结构也相应编程偏向锁的结构,保存着这个线程的信息。当下一次该线程再次请求锁的时候,需要比较当前线程的threadID和Mark Word中的threadID是否一致,如果一致,则无需使用CAS来加锁、解锁。如果不一致,对象会发现不止一个线程而是有多个线程正在竞争锁,那么就会升级为轻量级锁。
轻量级锁
Lock Record=Displaced Mark Word+Owner
线程在执行同步代码块之前,每个的栈桢都新建一个锁记录的结构,提前将对象的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
线程尝试通过CAS 将锁对象的 Mark Word 更新为指向Lock Record的指针,如果更新成功,该线程获取到轻量级锁,并且需要把对象头的Mark Word的低两位改成10。
线程获取轻量级锁失败,锁膨胀为重量级锁,对象头的Mark Word改为指向重量级锁monitor的指针。获取失败的线程不会立即阻塞,先适应性自旋,尝试获取锁。到达临界值后,阻塞该线程,直到被唤醒。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针
补充
- 锁只能升级,不能降级