上一篇文章《单例模式(Java)》中讲到了 双重检查单例模式DCL,里面涉及到了两个关键字 synchronized和 volatile,关于它们的知识点还是很多的,这篇文章我想先介绍一下 synchronized,并不涉及 DCL单例模式本身,下一篇文章会结合 DCL单例模式详细介绍 volatile。
synchronized的作用
首先看一段测试代码,为了验证在高并发的情况下会出现线程不安全的情况:
public class ThreadTest {
//定义一个int型静态变量变量,初始值为0
private static int count = 0;
public static void main(String[] args){
//main方法里面通过for循环开启10个线程。每个线程执行10000次count++
for(int j=0;j<10;j++){
new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<10000;i++){
count++;
}
}
}).start();
}
//for循环执行完毕之后,主线程会在这里睡10秒,保证上面10个线程执行完毕
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//最后打印输出count值
System.out.println(count);
}
}
多次运行上面这段代码,打印count值,并不是100000。这就说明多个线程同时操作一个对象或者一个类会出现线程不安全的现象。
举一个例子,便于大家理解这个问题:
公共厕所的一个隔间就相当于一个对象或者一个类,里面的便池就相当于对象或类的方法属性。上厕所的人就相当于一个线程。假如你进入隔间方便,但是没有锁门,其他人(其他线程)可以随意进出,那么肯定会出现不可预知的问题。哈哈哈哈哈~~
synchronized的作用就是加锁,加锁之后,释放锁之前,对象或者类就属于自己私有的东西,是不允许其他任何线程使用的,其他线程需要使用的时候就只能在旁边等着。就相当于你上厕所的时候把门锁了,一个道理,其他人只能在门外等你解决完。
synchronized的三种使用方式
1.synchronized语句块
synchronized()内部放一个对象,表示执行该代码块的时候锁住该对象。
public static void main(String[] args){
Object o = new Object();
//main方法里面通过for循环开启10个线程。每个线程执行10000次count++
for(int j=0;j<10;j++){
new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<10000;i++){
synchronized(o){
count++;
}
}
}
}).start();
}
synchronized()内部放一个Class类对象,表示执行该代码块的时候锁住该类。
public static void main(String[] args){
Object o = new Object();
//main方法里面通过for循环开启10个线程。每个线程执行10000次count++
for(int j=0;j<10;j++){
new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<10000;i++){
synchronized(ThreadTest.class){
count++;
}
}
}
}).start();
2.synchronized修饰成员方法
//执行该方法的时候,会锁住相关的对象
public synchronized void add(){
num++;
}
3.synchronized修饰静态方法
//执行该方法的时候,会锁柱对应的类
public synchronized static void add(){
count++;
}
总结上面的三种使用方法,其实归根结底就是锁对象和锁类
锁对象:锁住的是一个对象,因为一个类是可以创建很多对象的,锁对象只是锁住了其中的一个对象,并不影响其他的对象。
锁类:锁住的是一个类,一个类可以有很多个实例,但是被加载的类只有一个。锁住类代表着锁住了该类所有的对象以及类本身。
synchronized的特性
1.原子性
原子即为不可分割的整体,拒绝多线程产生的中断操作,即同时只能有一个线程完整的执行一系列操作
一句简单的代码会对应多条指令
private static int num = 0;
num++;
上面的num++操作可以分为读num值、对num加1操作、将num写回主内存,所以多个线程同时进行num++操作,就会出现问题。比如A线程和B线程都执行完了读num值,读到的都是0,然后先后执行了对num加1操作,得到的就都是1,将num写回主内存也就都是1,所以两次加1操作的结果等同于一次。
由于synchronized的加锁操作,被它修饰的代码块或方法都是具有原子性的,一个线程获得锁之后,会完整的执行完相关一系列操作,其他所有线程处于等待状态。
2.线程可见性
线程可见性,即保证各个线程之间共用资源的实时性和正确性
这里首先介绍一下JVM的相关内存模型:
每个线程会有自己的工作内存,堆内存(对JVM内存模型不熟悉的话,也可以理解为主内存)是所有线程共享的,一些公共的资源是放在堆内存里面的,线程为了提升工作效率,会把自己用到的资源拷贝一份放到自己线程的工作内存中,需要使用的时候,会直接从自己的工作内存中读取,线程对于数据的修改,也会先写到自己的工作内存中,之后再从工作内存写回堆内存。但是堆内存中的公共资源是不断更新的,什么时候重新将堆内存中的资源更新到自己的工作内存中是不确定的,这就导致了,其他线程对于公共资源的修改,本线程并未及时察觉到,即线程不可见。
synchronized是如何保证线程可见性的呢?
其实还是很好理解的,一是执行synchronized加锁操作之前,JVM会将对应线程的工作内存清空,重新去堆内存中读取公共资源,释放锁之前,会将工作内存里面所有的公共资源更新到堆内存;二是加锁之后,释放锁之前是不允许其他线程访问相关资源的。上述两点原因也就保证了线程可见性。
3.可重入
可重入可以简单理解为锁中锁
public class ThreadTest1 {
public static void main(String[] args){
synchronized (ThreadTest1.class){
System.out.println("进入锁");
synchronized (ThreadTest1.class){
System.out.println("锁中锁");
}
}
}
}
上面两句代码都是可以输出的,锁是针对于线程的,上锁是为了不让其他线程拿到锁,我会在下面锁升级部分详细说明,上锁的原理。
synchronized锁升级过程
synchronized在之前的JDK版本(我忘记是哪个版本了emmm),会直接向CPU内核态申请重量级锁,由于应用一般是和CPU用户态打交道的,一旦需要访问内核态,就会导致效率降低,所以之前的synchronized效率还是比较低的。之后的版本对synchronized做了一定的优化,让其经历一个过程,最终才可以升级为重量级锁,以提高效率。所以就有了synchronized锁升级过程。
在介绍锁升级过程之前,我首先介绍一下一个普通对象的内存结构如下图:锁的相关信息会记录在对象头部的markword字段
为了让大家更加直观的看到对象的内存结构,我实例化一个Object对象,并打印它的内存结构。之后对这个对象加锁,再次打印它的内存结构:
public class ThreadTest {
public static void main(String[] args){
//新建一个Object对象
Object o = new Object();
//打印对象o的内存结构
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized(o){
//加锁-打印对象o的内存结构
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
可以很明显的看到加锁前后的变化,即synchronized相关锁信息,会记录在对象的markword字段,那记录的是什么呢?我上面提到过锁是针对于线程的,所以对象的markword字段记录的锁信息,其实就是相应的线程信息。
下面开始介绍锁升级的四个过程:
1.无锁(刚刚new出对象)
刚刚实例化的对象就是一个无锁的状态,上面的图中可以看到,新创建对象的markword字段,除了第一个字节,全为0,也就是说它没有记录任何线程信息。
2.偏向锁(开始有第一个线程加锁征用对象)
这个时候来了一个线程,通过synchronized锁住了对象,该线程会把自己的线程指针Java Thread写到对象的markword字段,表示本线程征用该对象。假如下一次本线程继续使用该对象,发现markword字段的线程指针指向自己,那么就可以直接使用。这是一把偏向自己的锁,所以叫做偏向锁。
3.自旋锁(出现多个线程竞争对象)
出现了其他的线程也想使用此对象,那么撤销偏向锁,升级为自旋锁。每个线程都会有自己的线程栈,每个线程栈会生成一个Lock Record对象,不同线程对于对象的竞争,实际上就是将自己的Lock Record指针写到对象的markword字段,指针指向哪个Lock Record,对象就属于哪个线程。
为什么叫自旋锁呢?因为得到锁的线程会执行程序,得不到锁的线程会进行自旋操作(CAS),不断尝试将自己的Lock Record指针写到markword上,就像原地转圈,所以叫做自旋锁。
4.重量级锁(线程数过多或者是自旋次数过多)
自旋锁效率相对于重量级锁效率更高,但是会消耗CPU。假如很多的线程一直自旋,那么CPU会吃不消的。所以在一定情况下,会向内核态申请,升级为重量级锁。一旦成为重量级锁,等待线程将停止自旋,进入等待队列,等待队列中的线程是不会占用CPU的。
关于synchronized的分享到这里就结束了,希望大家喜欢哇
下一篇我会结合DCL单例模式和大家聊一聊volatile关键字,希望大家多多支持!