并发编程艺术学习笔记-02-Java并发机制的底层实现原理

文章目录

  • 1、volatile的应用
    • 1.1 volatile定义与实现原理
    • 1.2 volatile特性
  • 2、synchronized的实现原理与应用
    • 2.1 Java对象头
    • 2.2 锁的升级过程
      • 2.2.1 偏向锁
      • 2.2.2 轻量级锁
    • 2.3 synchronized的介绍

1、volatile的应用

在多线程并发编程中 synchronizedvolatile 都是很重要的,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 “可见性” ,其中可见性是指当一个线程修改了一个共享变量时,另一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比syschronized的使用成本和执行成本更低,因为它不会引起线程上下文切换和调度。

1.1 volatile定义与实现原理

volatile定义Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。 Java语言提供了volatile,在某种情况下比锁要更加方便。如果一个字段被声明成volatile,Java内存模型确保所有线程看到这个变量的值是一致的。

实现原理:如果一个变量使用volatile修饰之后,在进行编译生成汇编代码时,会在前面添加 Lock 指令。Lock指令在多核处理器下会引发两件事情:

  • 1)将当前处理器缓存行的数据立即写回内存
  • 2)写回内存行的操作会导致在其他CPU里缓存了该内存地址的数据无效。在多个处理器协同下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。实际工作的时候,当处理器发现自己的缓存行中的内存地址被修改,就会将当前处理器中的缓存值设置为无效状态。当需要使用该变量时,会重新从内存中读取该变量的值到缓存中,因此确保了每次读取的值是最新的

1.2 volatile特性

在讲完实现原理之后,我感觉很有必要介绍volatile的特性:可见性非原子性有序性

  • 可见性: 对于可见性,Java提供了volatile关键字保证了可见性。当一个变量被volatile修饰时,则这个变量对其他线程是可见的(即如果这个变量值在一个线程中被改变,在另外一个线程中是可以同步看见的)。而普通的共享变量不能保重可见性,因为普通变量被修改后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
    另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
  • 非原子性: 讲解非原子性,我们来看一段代码:
/*
	生成10个线程,并进行累加操作
*/
package test1;
public class Test {

	private volatile static int i = 0;

	private static void increase() {
		i++;
	}
	public static void main(String[] args) {
		for (int k = 0; k < 10; k++) {
			Thread t = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int j = 0; j < 1000; j++) {
						increase();
					}
				}
			});
			t.start();
		}
		while (Thread.activeCount() > 1) // 保证前面的线程都执行完
			Thread.yield();
		System.out.println(i);
	}
}

在运行完程序发现:每次运行的结果都是不一样的,且每个结果值都是小于10000的值。按理来说,使用了volatile修饰的变量i,在每次的累加操作时,应该确保使用的是最新的值啊,为什么这里会产生错误?问题就出现在这:volatile不能保证原子性,它只能保证每次读取的值是最新值,但不能保证累加操作是原子性操作。累加操作包括读取变量的原始值、进行加1操作、写入内存。则进行累加操作需要3个步骤完成。可能会产生下面过程:
 1、假如某个时刻变量 i 的值为10
 2、线程1对变量进行自增操作,线程1先读取了变量 i 的原始值,然后线程1被阻塞了,并未完成加1操作;
 3、然后此时线程2对变量进行自增操作,线程2也去读取变量 i 的原始值,由于线程1只是对变量 i 进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量 i 的缓存行无效,所以线程2会直接去主存读取 i 的值,发现 i 的值时10,然后进行加1操作,但并没有写入内存
 4、然后线程1接着进行加1操作,由于已经读取了 i 的值,注意此时在线程1的工作内存中 i 的值仍然为10,所以线程1对 i 进行加1操作后 i 的值为11

 5、线程1和线程2同时将i的值写入到内存,导致最终结果还是为11 
 那么两个线程分别进行了一次自增操作后,i 只增加了1。
 在解决这个原子性问题时,如果我们想让这个++操作是正常的不出现错误,有2种措施:
 1、使用synchronized进行同步increase( )方法
 2、使用Atomticinteger 修饰变量 i
 措施1:

/*
使用synchronized修饰自增方法,加上同步锁
*/
package test1;

public class Test {

	private volatile static int i = 0;

	private static synchronized void increase() {
		i++;
	}

	public static void main(String[] args) {
		for (int k = 0; k < 10; k++) {
			Thread t = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int j = 0; j < 1000; j++) {
						increase();
					}
				}
			});
			t.start();
		}
		while (Thread.activeCount() > 1) // 保证前面的线程都执行完
			Thread.yield();
		System.out.println(i);
	}
}

措施2:

/*
	使用原子性操作的结果,正确运行
*/
package test1;
import java.util.concurrent.atomic.AtomicInteger;

public class Test {

	//使用AtomicInteger保证每一次的操作是原子性的
	private static AtomicInteger i = new AtomicInteger(0);

	private static void increase() {
		i.getAndIncrement();
	}

	public static void main(String[] args) {
		for (int k = 0; k < 10; k++) {
			Thread t = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int j = 0; j < 1000; j++) {
						increase();
					}
				}
			});
			t.start();
		}
		while (Thread.activeCount() > 1) // 保证前面的线程都执行完
			Thread.yield();
		System.out.println(i);
	}
}

  • 有序性: 在java内存模型中说过,为了性能优化,编译器和处理器会进行指令重排序;也就是说java程序天然的有序性可以总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。 在单例模式的实现上有一种双重检验锁定的方式(Double-checked Locking),代码如下:
public class Singleton {
    private Singleton() { }
    private volatile static Singleton instance;
    public Singleton getInstance(){
        if(instance==null){
            synchronized (Singleton.class){
                if(instance==null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里为什么要加volatile了?我们先来分析一下不加volatile的情况,有问题的语句是这条

 instance = new Singleton();

这条语句实际上包含了三个操作:1.分配对象的内存空间;2.初始化对象;3.设置instance指向刚分配的内存地址。但由于存在重排序的问题,可能有以下的执行顺序:
并发编程艺术学习笔记-02-Java并发机制的底层实现原理_第1张图片
如果2和3进行了重排序的话,线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,显而易见对线程B来说之后的操作就会是错得。而用volatile修饰的话就可以禁止2和3操作重排序,从而避免这种情况。volatile包含禁止指令重排序的语义,其具有有序性。

2、synchronized的实现原理与应用

说到synchronized,我感觉每一个搞开发的都对它太熟悉太熟悉了。那我们就同时学习一下这个元老级别的锁,很多人都称呼它为重量级锁 (后面会介绍到,关于偏向锁->轻量级锁->重量级锁 的过程)。但是,在JavaSE1.6之后,对synchronized进行了各种优化,导致也不是那么重了。在引入这个所谓重量级锁的前提,想必应该了解一下,在锁进行不断的变化过程中,它的变化过程,也即 锁的存储结构和升级过程

在了解锁的原理之前,应该了解 Java对象头 ,这是一个很重要的概念。

2.1 Java对象头

synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果是非数组类型,则用2个字宽存储对象头。在32位虚拟机中,1字宽等于4个字节,即32bit,Java对象头如表的长度如图所示。

Alt
Java对象头的 Mark Word里默认存储对象的HashCode或锁的信息(锁的状态、分代年龄、锁标志位)。32位JVM的Mark Word的默认存储结构如下表所示。
Alt
在运行期间,Mark Word里存储的数据会随着标志位的变化而变化。Mark Word可能变化位存储以下4种数据。并发编程艺术学习笔记-02-Java并发机制的底层实现原理_第2张图片
从上面的红色框框,可以看出在线程在每一次的切换时,都会判断该对象是否有锁标记位,通过标记位可以识别出当前状态。
JavaSE1.6位了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和**“轻量级锁”** ,锁一共有4种状态,级别从高到低依次是:无锁状态–>偏向锁–>轻量级锁–>重量级锁。这几个状态会随着竞争情况逐渐加强。锁可以升级,但一旦升级之后,就无法降级,表明能从低级锁升级成为高级锁之后,但无法从高级锁降级位低级锁,这种只升不降的策略是为了提高获得锁和释放锁的效率

2.2 锁的升级过程

前面也介绍了,锁在进行竞争之时,会不断升级,会产生不同的状态锁。

2.2.1 偏向锁

大多数情况下,锁不仅存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁 ,当一个线程访问同步块并获取锁时,会向对象头和栈帧中的锁记录里存锁偏向的线程ID,以后该线程进入和退出同步块时不需要进行CAS操作(上篇文章介绍过)来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得锁。如果测试失败,则需要再测试一下Mark Word种偏向锁的标识是否设置为1(表示当前是偏向锁);如果没有设置,使用CAS竞争锁;如果设置了,则尝试使用CAS将对象偏向锁指向当前线程。

偏向锁的获得和撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待安全点(在这个时间点上没有正在执行的字节码)。首先暂停拥有偏向锁的线程,然后检查持有锁的线程是否还活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程还处于活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

并发编程艺术学习笔记-02-Java并发机制的底层实现原理_第3张图片
偏向锁在Java6和Java7里默认是开启的,但是它在应用程序启动几秒后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果确定在应用程序里所有的锁通常存在竞争,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

2.2.2 轻量级锁

  • 轻量级锁加锁
    线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。
  • 轻量级锁解锁
    轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生;如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁如图所示。
    并发编程艺术学习笔记-02-Java并发机制的底层实现原理_第4张图片
    由于自旋很消耗资源,为了避免无用的自旋(比如获得锁的线程被阻塞了),一旦升级成重量级锁,就不会再回到轻量级锁状态。当处于这个状态下,其他线程试图想要获取锁时,都会被阻塞,只有当持有锁的线程释放掉之后,会唤醒这些阻塞的线程,被唤醒的线程就会进入新一轮的锁竞争状态去
    所以在进行锁竞争的时候,想要获取锁的线程在没有竞争到的时候会进行自旋操作,当自旋后发现还是没有机会得到,就会阻塞。

所以我感觉这个图是对于上面整体的简化:
并发编程艺术学习笔记-02-Java并发机制的底层实现原理_第5张图片

2.3 synchronized的介绍

在前面花了大篇幅讲解了锁的各种状态,介绍了锁从一步一步慢慢演化到了重量级锁,利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体有如下3种形式:

  • 对于普通同步方法,锁是当前实例对象;
  • 对于静态同步方法,锁是当前类的Class对象;
  • 对于同步方法块,锁是synchronized括号里面配置的对象;

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须先释放锁。
同步代码块是使用Monitorenter和Monitorexit指令实现的,而方法同步是使用另外一种方法实现的。但是,方法的同步同样可以使用这两个指令来实现。

下面分别从不同的方法实现上面所介绍的锁方式;
同步代码块实现锁:

/* 
	使用代码块实现同步锁
*/
package test2;

class TicketSale implements Runnable {

	private int ticket = 25;

	@Override
	public void run() {
		while (true) {
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (this) {
				if (ticket > 0) {
					System.out.println(Thread.currentThread().getName() + "卖了:" + ticket);
					ticket--;
				} else {
					break;
				}
			}
		}
	}

}

public class Test1 {

	public static void main(String[] args) {
		TicketSale ticketSale = new TicketSale();
		Thread[] t = new Thread[10];
		for (int i = 0; i < 10; i++) {
			t[i] = new Thread(ticketSale);
			t[i].start();
		}
	}
}

运行结果:
Thread-5卖了:25
Thread-2卖了:24
Thread-1卖了:23
Thread-3卖了:22
Thread-6卖了:21
Thread-7卖了:20
Thread-4卖了:19
Thread-9卖了:18
Thread-8卖了:17
Thread-0卖了:16
Thread-3卖了:15
Thread-6卖了:14
Thread-5卖了:13
Thread-1卖了:12
Thread-7卖了:11
Thread-0卖了:10
Thread-2卖了:9
Thread-8卖了:8
Thread-4卖了:7
Thread-9卖了:6
Thread-1卖了:5
Thread-7卖了:4
Thread-5卖了:3
Thread-8卖了:2
Thread-4卖了:1

此时通过同步代码块实现了正确的卖票过程。
同步方法实现锁:

/* 
	使用锁方法实现锁
*/
package test2;

class TicketSale implements Runnable {

	private static int ticket = 25;

	@Override
	public void run() {
		while (ticket > 0) {
			sell();
		}

	}

	public synchronized void sell() {
		if (ticket > 0) {
			System.out.println(Thread.currentThread().getName() + "卖了:" + ticket);
			ticket--;
		}
	}
}

public class Test2 {
	public static void main(String[] args) {
		TicketSale ticketSale = new TicketSale();
		Thread[] t = new Thread[10];
		for (int i = 0; i < 10; i++) {
			t[i] = new Thread(ticketSale);
			t[i].start();
		}
	}
}
实现结果:
Thread-2卖了:25
Thread-2卖了:24
Thread-2卖了:23
Thread-2卖了:22
Thread-2卖了:21
Thread-2卖了:20
Thread-2卖了:19
Thread-1卖了:18
Thread-1卖了:17
Thread-1卖了:16
Thread-1卖了:15
Thread-1卖了:14
Thread-1卖了:13
Thread-0卖了:12
Thread-0卖了:11
Thread-0卖了:10
Thread-0卖了:9
Thread-0卖了:8
Thread-0卖了:7
Thread-0卖了:6
Thread-0卖了:5
Thread-0卖了:4
Thread-0卖了:3
Thread-0卖了:2
Thread-0卖了:1

在解决上面的问题时,出现了很多的小问题,针对synchronized后面会继续更新,当前只是做了一个简单的实现。后面会针对一半同步,一半异步代码间的同步、与静态同步synchronized方法和synchronized(this) 等之间的验证比较。

你可能感兴趣的:(并发编程)