该文章属于《Java并发编程》系列文章,如果想了解更多,请点击《Java并发编程之总目录》
前言
上篇文章我们讲了volatile关键字,我们大致了解了其为轻量级的同步机制,现在我们来讲讲我们关于同步的另一个兄弟synchronized。synchronized作为开发中常用的同步机制,也是我们处理线程安全的常用方法。相信大家对其都比较熟悉。但是对于其内部原理与底层代码实现大家有可能不是很了解,下面我就和大家一起彻底了解synchronized的使用方式与底层原理。
线程安全的问题
线程安全的定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。
在具体讲解synchronized之前,我们需要了解一下什么是线程安全,为什么会出现线程线程不安全的问题。请看下列代码:
class ThreadNotSafeDemo {
private static class Count {
private int num;
private void count() {
for (int i = 1; i <= 10; i++) {
num += i;
}
System.out.println(Thread.currentThread().getName() + "-" + num);
}
}
public static void main(String[] args) {
Runnable runnable = new Runnable() {
Count count = new Count();
public void run() {
count.count();
}
};
//创建10个线程,
for (int i = 0; i < 10; i++) {
new Thread(runnable).start();
}
}
}
上述代码中,我们创建Count类,在该类中有一个count()方法,计算从1一直加到10的和,在计算完后输出当前线程的名称与计算的结果,我们期望线程输出的结果是首项为55且等差为55的等差数列。但是结果并不是我们期望的。具体结果如下图所示:
我们可以看见,线程并没有按照我们之间想的那样,线程按照从Thread-0到Thread-9依次排列,并且Thread-0与Thread-1线程输出的结果是错误的。
之所以会出现这样的情况,是CPU在调度的时候线程是可以交替执行的,具体来讲是因为当前线程Thread-0求和后,(求和后num值为55),在即将执行打印语句时,突然CPU开始调度执行Thread-1去执行count()方法,那么Thread-0就会停留在即将打印语句的位置,当Thread-1执行计算和后(求和后num值为100),这个时候CPU又开始调度Thread-0执行打印语句。则Thread-1开始暂停,而这个时候num值已经为110了,所以Thread-0打印输出的结果为110。
线程安全的实现方法
上面我们了解了之所以会出现线程安全的问题,主要原因就是因为存在多条线程共同操作共享数据,同时CPU的调度的时候线程是可以交替执行的。导致了程序的语义发生改变,所以会出现与我们预期的结果违背的情况。因此为了解决这个问题,在Java中提供了两种方式来处理这种情况。
互斥同步(悲观锁)
互斥同步是指当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行。
在Java中最基本的互斥同步就是synchronized(这里我们讨论的是jdk1.6之前,在jdk1.6之后Java团队对锁进行了优化,后面文章会具体描述),也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。
除了synchronized之外,我们还可以使用java.util.concurrent包下的ReentrantLock来实现同步。
非阻塞式同步(乐观锁)
互斥同步主要的问题就是进行线程阻塞和唤醒锁带来的性能问题,为了解决这性能问题,我们有另一种解决方案,当多个线程竞争某个共享数据时,没有获得锁的线程不会阻塞,而是不断的尝试去获取锁,直到成功为止。这种方案的原理就是使用循环CAS操作来实现。
synchronized的三种使用方式
了解了synchronized的解决的问题,那么我们继续来看看在Java中在Java中synchronized的使用情况。
在Java中synchronized主要有三种使用的情况。下面分别列出了这几种情况
- 修饰普通的实例方法,对于普通的同步方法,锁式当前实例对象
- 修饰静态方法,对于静态同步方法,锁式当前类的Class对象
- 修饰代码块,对于同步方法块,锁是Synchronized配置的对象
证明当前普通的同步方法,锁式当前实例对象
为了证明普通的同步方法中,锁是当前对象。请观察以下代码:
class SynchronizedDemo {
public synchronized void normalMethod() {
doPrint(5);
}
public void blockMethod() {//注意,同步块方法块中,配置的是当前类的对象
synchronized (this) {
doPrint(5);
}
}
//打印当前线程信息与角标值
private static void doPrint(int index) {
while (index-- > 0) {
System.out.println(Thread.currentThread().getName() + "--->" + index);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
SynchronizedDemo demo = new SynchronizedDemo();
new Thread(() -> demo.normalMethod(), "testNormalMethod").start();
new Thread(() -> demo.normalMethod(), "testBlockMethod").start();
}
}
在上诉代码中,分别创建了两个方法,normalMethod()与blockMethod()方法,其中normalMethod()方法为普通的同步方法,blockMethod()方法中,是一个同步块且配置的对象是当前类的对象。在Main()方法中,分别创建两个线程执行两个不同的方法。
程序输出结果
观察程序输出结果,我们可以看到normalMethod方法是由于blockMethod方法执行的,且blockMethod方法是在normalMethod方法执行完成之后在执行的。也就证明了我们的对于普通的同步方法锁式当前实例对象的结论。
证明对于静态同步方法,锁式当前类的Class对象
class SynchronizedDemo {
public void blockMethod() {
synchronized (SynchronizedDemo.class) {//注意,同步块方法块中,配置的是当前类的Class对象
doPrint(5);
}
}
public static synchronized void staticMethod() {
doPrint(5);
}
/**
* 打印当前线程信息
*/
private static void doPrint(int index) {
while (index-- > 0) {
System.out.println(Thread.currentThread().getName() + "--->" + index);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
SynchronizedDemo demo = new SynchronizedDemo();
new Thread(() -> demo.blockMethod(), "testBlockMethod").start();
new Thread(() -> demo.staticMethod(), "testStaticMethod").start();
}
}
在有了第一个结论的证明后,对于静态同步方法的锁对象就不再进行描述了(但是大家要注意一下,同步方法块中配置的对象是当前类的Class对象)。下面直接给出输出结果:
观察结果,也很明显的证明了对于静态同步方法,锁式当前类的Class对象的结论
Synchronized的原理
下面文章主要是讲解jdk1.6之后Java团队对锁进行了优化之后的原理,优化之后涉及到偏向锁、轻量级锁、重量级锁。其中该文章都涉及jdk源码,这里把最新的jdk源码分享给大家----->jdk源码)
在了解Synchronized的原理的原理之前,我们需要知道三个知识点第一个是CAS操作,、第二个是Java对象头(其中Synchronized使用的锁就在对象头中)、第三个是jdk1.6对锁的优化。在了解以上三个知识点后,再去理解其原理就相对轻松一点。关于CAS操作已经在上篇文章《Java并发编程之Java CAS操作》进行过讲解,下面我们来讲解关于Java对象头与锁优化的知识点。
Java对象的内存布局
在Java虚拟机中,对象在内存的存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)。其中虚拟机中的对象头包括三部分信息,分别为"Mark Word"、类型指针、记录数组长度的数据(可选),具体情况如下图所示:
Java对象头的组成
- “Mark Word“:第一部分用于存储对象自身的运行时数据。如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向锁时间戳等,这部分的数据在长度32位与64位的虚拟机中分别为32bit和64bit,官方称为“Mark Word"。
- 类型指针:对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(Ja