Java多线程编程

什么是线程

首先我们需要知道什么是进程,当我们启动一个应用程序时,操作系统会创建一个进程。进程是一个抽象的概念,如果落到实际物理意义上,简单可以理解成内存中的一段连续地址空间。而线程是进程中的一个实体,线程本身是不会独立存在的。进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径。

一个进程中至少有一个线程,进程中的多个线程共享进程的资源。操作系统在分配资源时是把资源分配给进程的,但是CPU资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是CPU分配的基本位。

在Java中,当我们启动main函数时其实就启动了一个口JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称主线程。
进程及线程关系
由上图可以看到,一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。

 

那么为何要将程序计数器设计为线程私有的呢?
前面说了线程是占用CPU执行的基本单位,而CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU时间片用完后,要让出CPU,等下次轮到自己的时候再执行。

那么如何知道之前程序执行到哪里了呢?
其实程序计数器就是为了记录该线程让出CPU时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。

另外每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用new操作创建的对象实例。方法区则用来存放JVM加载的类、常量及静态变量等信息,也是线程共享的。

线程创建与运行

Java中有三种线程创建方式,分别为实现Runnable接口的run方法、继承Thread类并重写run的方法、使用FutureTask方式。Runnable接口和继承Thread都有一个缺点,就是任务没有返回值。而FutureTask的方式则具有返回值。

继承Thread类创建线程

 

public class ThreadTest extends Thread {
	@Override
	public void run() {
		// TODO Auto-generated method stub
		System.out.println("这是一个线程!");
	}

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		// 创建线程
		ThreadTest threadTest = new ThreadTest();
		// 启动线程
		threadTest.start();
	}

}

 

实现Runnable接口创建线程

 

public class RunnableTest implements Runnable{

	@Override
	public void run() {
		// TODO Auto-generated method stub
		System.out.println("这是一个线程!");
	}
	
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		// 创建线程
		RunnableTest runnableTest = new RunnableTest();
		// 启动线程
		new Thread(runnableTest).start();
	}

}

FutureTask方式创建线程

public class CallableTest implements Callable<String> {

	@Override
	public String call() throws Exception {
		// TODO Auto-generated method stub
		return "Hello world!";
	}

	public static void main(String[] args) throws InterruptedException {
		// TODO Auto-generated method stub
		// 创建异步任务
		FutureTask<String> futureTask = new FutureTask<>(new CallableTest());
		// 启动线程
		new Thread(futureTask).start();
		try {
			// 等待任务执行完毕,并返回结果
			String result = futureTask.get();
			System.out.println(result);
		} catch (ExecutionException e) {
			e.printStackTrace();
		}
	}

}

小结: 使用继承方式的好处是方便传参,你可以在子类里面添加成员变量,通过set方法设置参数或者通过构造函数进行传递,而如果使用Runnable方式,则只能使用主线程里面被声明为final的变量。不好的地方是Java不支持多继承,如果继承了Thread类,那么子类不能再继承其他类,而Runable则没有这个限制。前两种方式都没办法拿到任务的返回结果,但是Futuretask方式可以。

线程上下文切换

在多线程编程中,线程个数一般都大于CPU个数,而每个CPU同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用CPU执行任务。当前线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程。

那么就有一个问题,让出CPU的线程等下次轮到自己占有CPU时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场,当再次执行时根据保存的执行现场信息恢复执行现场。在理解这个之前,要理解程序计数器的作用:
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。且由于java虚拟机的多线程是通过线程轮流切换并分配器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器是一个内核)都只会执行一条线程中的指令,因为为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,我们称这类区域为“线程私有”的内存。线程切换时需要知道在这之前当前线程已经执行到哪条指令了,所以需要记录程序计数器的值,另外比如说线程正在进行某个计算的时候被挂起了,那么下次继续执行的时候需要知道之前挂起时变量的值时多少,因此需要记录CPU寄存器的状态。所以一般来说,线程上下文切换过程中会记录程序计数器、CPU寄存器状态等数据。

线程死锁

线程死锁是指两个或两个以上的线程互相持有对方所需要的资源,由于synchronized的特性,一个线程持有一个资源,或者说获得一个锁,在该线程释放这个锁之前,其它线程是获取不到这个锁的,在无外力作用情况下会一直死等下去,因此这便造成了死锁,如下图所示。
线程死锁
在上图中,线程A己经持有了资源2,它同时还想申请资源1,线程B已经持有了资源1,它同时还想申请资源2,所以线程1和线程2就因为相互等待对方已经持有的资源,而进入了死锁状态。那么为什么会产生死锁呢?学过操作系统的朋友应该都知道,死锁的产生必须具备以下四个条件。

  • 互斥条件:指线程对己经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  • 占有且等待:指一个线程己经持有了至少一个资源,但又提出了新的资源请求,而新资源己被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己己经获取的资源。
  • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
  • 环路等待条件:指在发生死锁时,必然存在一个线程→资源的环形链,即线程集合{T0,T1,T2,…,Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待己被T0占用的资源。

死锁例子

public class ThreadDeadLockTest {

	// 创建资源
	private static Object resourceA = new Object();
	private static Object resourceB = new Object();

	public static void main(String[] args) {
		// 创建线程A
		Thread threadA = new Thread(new Runnable() {

			@Override
			public void run() {
				// TODO Auto-generated method stub
				synchronized (resourceA) {
					System.out.println("ThreadA:已经得到资源A");
					try {
						Thread.sleep(1000);
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
					System.out.println("ThreadA:正在等待获取资源B");
					synchronized (resourceB) {
						System.out.println("ThreadA:已经得到资源B");
					}
				}
			}

		});

		// 创建线程B
		Thread threadB = new Thread(new Runnable() {

			@Override
			public void run() {
				// TODO Auto-generated method stub
				synchronized (resourceB) {
					System.out.println("ThreadB:已经得到资源B");
					try {
						Thread.sleep(1000);
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
					System.out.println("ThreadB:正在等待获取资源A");
					synchronized (resourceA) {
						System.out.println("ThreadB:已经得到资源A");
					}
				}
			}

		});
		
		threadA.start();
		threadB.start();
	}
}

下面分析代码和结果:Thread-0是线程A,Thread-1是线程B,代码首先创建了两个资源,并创建了两个线程。从输出结果可以知道,线程调度器先调度了线程A,也就是把CPU资源分配给了线程A,线程A使用synchronized(resourceA)方法获取到了resourceA的监视器锁,然后调用sleep函数休眠1s,休眠1s是为了保证线程A在获取resourceB对应的锁前让线程B抢占到CPU,获取到资源resourceB上的锁。线程A调用sleep方法后线程B会执行synchronized(resourceB)方法,这代表线程B获取到了resourceB对象的监视器锁资源,然后调用sleep函数休眠1s。好了,到了这里线程A获取到了resourceA资源,线程B获取到了resourceB资源。线程A休眠结束后会企图获取resourceB资源,而resourceB资源被线程B所持有,所以线程A会被阻塞而等待。而同时线程B休眠结束后会企图获取resourceA资源,而resourceA资源己经被线程A持有,所以线程A和线程B就陷入了相互等待的状态,也就产生了死锁。下面谈谈本例是如何满足死锁的四个条件的。

首先,resourceA和resourceB都是互斥资源,当线程A调用synchronized(resourceA)方法获取到resourceA上的监视器锁并释放前,线程B再调用synchronized(resourceA)方法尝试获取该资源会被阻塞,只有线程A主动释放该锁,线程B才能获得,这满足了资源互斥条件。

线程A首先通过synchronized(resourceA)方法获取到resourceA上的监视器锁资源,然后通过synchronized(resourceB)方法等待获取resourceB上的监视器锁资源,这就构成了请求并持有条件。

线程A在获取resourceA上的监视器锁资源后,该资源不会被线程B掠夺走,只有线程A自己主动释放resourceA资源时,它才会放弃对该资源的持有权,这构成了资源的不可剥夺条件。

线程A持有objectA资源并等待获取objectB资源,而线程B持有objectB资源并等待objectA资源,这构成了环路等待条件。所以线程A和线程B就进入了死锁状态。

如何避免线程死锁

造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁,那么什么是资源申请的有序性呢?

如上代码让在线程B中获取资源的顺序和在线程A中获取资源的顺序保持一致就可避免线程死锁,其实资源申请有序性就是指,假如线程A和线程B都需要资源1,2,3,…,n时,对资源进行排序,线程A和线程B只有在获取了资源n-1时才能去获取资源n。

守护线程与用户线程

Java中的线程分为两类,分别为daemon线程(守护线程〉和user线程(用户线程)。在JVM启动时会调用main函数,main函数所在的钱程就是一个用户线程,其实在JVM内部同时还启动了好多守护线程,比如垃圾回收线程。那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程结束时,JVM会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响JVM的退出。言外之意,只要有一个用户线程还没结束,正常情况下JVM就不会退出。

//设置为守护线程
thread.setDaemon(true);

总结: 如果你希望在主线程结束后JVM进程马上结束,那么在创建线程时可以将其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让JVM进程结束,那么就将子线程设置为用户线程。

ThreadLocal类使用

多钱程访问同一个共享变量时特别容易出现并发问题,特别是在多个线程需要对一个共享变量进行写入时。为了保证线程安全,一般使用者在访问共享变量时需要进行适当的同步,如下图所示。
线程同步
同步的措施一般是加锁,这就需要使用者对锁有一定的了解,这显然加重了使用者的负担。那么有没有一种方式可以做到,当创建一个变量后,每个线程对其进行访问的时候访问的是自己线程的变量呢?其实ThreadLocal就可以做这件事情,虽然ThreadLocal并不是为了解决这个问题而出现的。

ThreadLocal是JDK包提供的,它提供了线程本地变量,也就是如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。创建一个ThreadLocal变量后,每个线程都会复制一个变量到自己的本地内存,如下图所示。
线程同步
总结: 如下图所示,在每个线程内部都有一个名为threadLocals的成员变量,该变量的类型为HashMap,其中key为我们定义的ThreadLocal变量的this引用,value则为我们使用set方法设置的值。每个线程的本地变量存放在线程自己的内存变量threadLocals中,如果当前线程一直不消亡,那么这些本地变量会一直存在,所以可能会造成内存溢出,因此使用完毕后要记得调用ThreadLocal的remove方法删除对应线程的threadLocals中的本地变量。
ThreadLocal类

lnheritableThreadLocal类使用

同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的。根据上节的介绍,这应该是正常现象,因为在子线程thread里面调用get方法时当前线程为thread线程,而这里调用set方法设置线程变量的是main线程,两者是不同的线程,自然子线程访问时返回null。那么有没有办法让子线程能访问到父线程中的值?答案是有。那就是lnheritableThreadLocal类。InheritableThreadLocal继承自ThreadLocal,其提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。

那么在什么情况下需要子线程可以获取父线程的threadlocal变量呢?比如子线程需要使用存放在threadlocal变量中的用户登录信息,再比如一些中间件需要把统一的id追踪的整个调用链路记录下来。其实子线程使用父线程中的threadlocal方法有多种方式,比如创建线程时传入父线程中的变量,并将其复制到子线程中,或者在父线程中构造一个map作为参数传递给子线程,但是这些都改变了我们的使用习惯,所以在这些情况下InheritabIeThreadLocal就显得比较有用。

Java 中共享变量的内存可见性问题

首先来看看在多线程下处理共享变量时Java的内存模型,如下图所示。
共享变量
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。Java内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?请看下图。
共享变量
图中所示是一个双核CPU系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU都共享的二级缓存。那么Java内存模型里面的工作内存,就对应这里的L1或者L2缓存或者CPU的寄存器。当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里变量进行处理,处理完后将变量值更新到主内存。那么假如线程A和线程B同时处理一个共享变量,会出现什么情况?我们使用上图所示CPU架构,假设线程A和线程B使用不同CPU执行,并且当前两级Cache都为空,那么这时候由于Cache的存在,将会导致内存不可见问题,具体看下面的分析。

  • 线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程A操作完毕后,线程A所在的CPU的两级Cache内和主内存里面的X的值都是1。
  • 线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里一切都是正常的,因为这时候主内存中也是X=1。然后线程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中,最后更新主内存中X的值为2:到这里一切都是好的。
  • 线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

那么如何解决共享变量内存不可见问题?使用Java中的volatile关键字就可以解决这个问题,下面会有讲解。

synchronized

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

另外,由于Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时伞,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。

synchronized的内存语义

前面介绍了共享变量内存可见性问题主要是由于线程的工作内存导致的,下面我们来讲解synchronized的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。

除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。

volatile关键字

上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。volatile的内存语义和synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。


private volatile int value;

public synchronized int getValue(){}

在这里使用synchronized和使用volatile是等价的,都解决了共享变量value的内存可见性问题,但是前者是独占锁,同时只能有一个线程调用getValue()方法,其他调用线程会被阻塞,同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。而后者是非阻塞算法,不会造成线程上下文切换的开销。但并非在所有情况下使用它们都是等价的,volatile虽然提供了可见性保证,但并不保证操作的原子性。

总之 synchronized是一种阻塞算法,volatile是非阻塞算法,前者保证操作的原子性,后者不保证。

那么一般在什么时候才使用volatile关键字呢?

  • 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取一>计算一>写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
  • 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为volatile的。

Java中的原子性操作

所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。

那么如何才能保证多个操作的原子性呢?最简单的方法就是使用synchronized关键字进行同步,代码如下。

public class ThreadSafeCount {
	private Long value;

	public synchronized Long getCount() {
		return value ;
	}
	public synchronized void setValue() {
		++value;
	}
}

使用synchronized关键宇的确可以实现线程安全性,即内存可见性和原子性,但是synchronized是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的getCount方法只是读操作,多个线程同时调用不会存在线程安全问题。但是加了关键宇synchronized后,同一时间就只能有一个线程可以调用,这显然大大降低了并发性。你也许会问既然是只读操作,那为何不去掉getCount方法上的synchronized关键字呢?其实是不能去掉的,别忘了这里要靠synchronized来实现value的内存可见性。那么有没有更好的实现呢?答案是肯定的,下面将讲到的在内部使用非阻塞CAS算法就是一个不错的选择。

CAS即CompareandSwap,其是JDK提供的非阻塞原子性操作,它通过硬件保证了比较更新操作的原子性。JDK里面的Unsafe类提供了一系列的compareAndSwap*方法。

boolean compareAndSwapLong(Object obj,long valueOffset,long expect,long update)
其中compareAndSwap的意思是比较并交换。CAS有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。其操作含义是,如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换旧的值expect。这是处理器提供的一个原子性指令。

在Java中,锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就是当一个线程没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开销。Java提供了非阻塞的volatile关键字来解决共享变量的可见性问题,这在一定程度上弥补了锁带来的开销问题,但是volatile只能保证共享变量的可见性,不能解决读、改、写等的原子性问题。

Java指令重排序

Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。

下面看一个例子。

int a = 1; (1)
int b = 2; (2)
int c = a + b; (3)

在如上代码中,变量c的值依赖a和b的值,所以重排序后能够保证(3)的操作在(2)(1)之后,但是(1)(2)谁先执行就不一定了,这在单线程下不会存在问题,因为并不影响最终结果。

下面看一个多线程例子

public class InstructionSortTest {
	static int x = 0, y = 0;
	static int a = 0, b = 0;

	public static void main(String[] args) throws InterruptedException {
		Thread one = new Thread(new Runnable() {
			public void run() {
				a = 1; // (1)
				x = b; // (2)
			}
		});

		Thread other = new Thread(new Runnable() {
			public void run() {
				b = 1; // (3)
				y = a; // (4)
			}
		});
		one.start();
		other.start();
		one.join();
		other.join();
		System.out.println("(" + x + "," + y + ")");
	}
}

如上代码在不考虑、 内存可见性问题的情况下一定会输出(0,1)? 答案是不一定,其运行结果可能为(1,0)、(0,1)或(1,1),因为代码(1)、(2)可能被重排序后执行顺序为(2)、(1),同理(3)、(4)可能被重排序后执行顺序为(4)、(3),这样在多线程情况下就会出现非预期的程序执行结果。

那有什么办法能解决上面的问题呢?答案是肯定的,我们可以使用volatile修饰我们定义的变量即可解决重排序和内存可见性问题。

写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。

伪共享

什么是伪共享
为了解决计算机系统中主内存与CPU之间运行速度差问题,会在CPU与主内存之间添加一级或者多级高速缓冲存储器(Cache)。这个Cache一般是被集成到CPU内部的,所以也叫CPUCache,下图所示是两级Cache结构。
2级缓存
在Cache内部是按行存储的,其中每一行称为一个Cache行。Cache行(如下图所示)是Cache与主内存进行数据交换的单位,Cache行的大小一般为2的幕次数字节。
缓存行
当CPU(多核心情况下)访问某个变量时,首先会去看CPUCache内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache行大小的内存复制到Cache中。由于存放到Cache行的是内存块而不是单个变量,所以可能会把多个变量存放到一个Cache行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将多个变量放到一个缓存行,性能会有所下降,导致缓存性能低下,这就是伪共享,如下图所示。
缓存
在上图中,变量x和y同时被放到了CPU的一级和二级缓存,当线程1使用CPU1对变量x进行更新时,首先会修改CPU1的一级缓存变量x所在的缓存行,这时候在缓存一致性协议下,CPU2中变量x对应的缓存行失效。那么线程2在写入变量x时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的CPU中相同缓存行里面的变量。更坏的情况是,如果CPU只有一级缓存,则会导致频繁地访问主内存。

为何会出现伪共享

伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量。 那么为何多个变量会被放入一个缓存行呢?其实是因为缓存与内存交换数据的单位就是缓存行,当CPU要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在内存中的大小为缓存行的内存放入缓存行。

long a;
long b;
long c;
long d;

如上代码声明了四个long变量,每个long类型变量占8个字节,假设缓存行的大小为32字节,那么当CPU访问变量a时,发现该变量没有在缓存中,就会去主内存把变量a以及内存地址附近的b、c、d放入缓存行,因为缓存行总大小为32字节,为填充满缓存行有可能会将内存地址附近的b、c、d一起放入该缓存行。当创建数组时,数组里面的多个元素就会被放入同一个缓存行。那么在单线程下多个变量被放入同一个缓存行对性能有影响吗?其实在正常情况下单线程访问时将数组元素放入一个或者多个缓存行对代码执行是有利的,因为数据都在缓存中,代码执行会更快。所以在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速了程序的运行。而在多线程下并发修改一个缓存行中的多个变量时就会竞争缓存行,从而降低程序运行性能。

如何避免伪共享

JDK8提供了一个sun.misc.Contended注解,用来解决伪共享问题。如下代码。

// 修饰类
@sun.misc.Contended
public final static class TestMisc {
public volatile long value = 0L ; 
}

// 修饰变量
@sun.misc.Contended("a")
long a;

Java锁

乐观锁与悲观锁

乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想,在后续我会单独介绍《MySql中乐观锁、共享锁、悲观锁、排它锁、行锁、表锁》。

公平锁 / 非公平锁

公平锁指多个线程按照申请锁的顺序来依次获取锁。非公平锁指多个线程获取锁的顺序并不是按照申请锁的顺序来获取,有可能后申请锁的线程比先申请锁的线程优先获取到锁,此极大的可能会造成线程饥饿现象,迟迟获取不到锁。由于ReentrantLock是通过AQS(抽象队列同步器)来实现线程调度,可以实现公平锁,但是synchroized是非公平的,无法实现公平锁。

  • 公平锁:ReentrantLock lock=new ReentrantLock(true)。
  • 非公平锁:ReentrantLock lock=new ReentrantLock(false)。如果构造函数不传递参数,则默认是非公平锁。

注意如果我们在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

独占锁 / 共享锁

独享锁是指该锁一次只能被一个线程所持有。共享锁是指可被多个线程所持有。在java中,对ReentrantLock对象以及synchroized关键字而言,是独享锁的。但是对于ReadWriteLock接口而言,其读是共享锁,其写操作是独享锁。读锁的共享锁是可保证并发读的效率,读写、写写、写读的过程中都是互斥的,独享的。独享锁与共享锁在Lock的实现中是通过 AQS(抽象队列同步器)来实现的。

互斥锁 / 读写锁

互斥锁与读写锁就是具体的实现,互斥锁在java 中的体现就是synchronized关键字以及Lock接口实现类ReentrantLock,读写锁在java中的具体实现就是ReentrantReadWriteLock。

可重入锁

又名递归锁,是指同一个线程在外层的方法获取到了锁,在进入内层方法会自动获取到锁。对于ReentrantLock和synchronized关键字都是可重入锁的。最大的好处就是能够避免一定程度的死锁。

下面看一个例子,看看在什么情况下会使用可重入锁。

public class Test{
	public synchronized void test1() {
		system.out.println("test1");
	}
	public synchronized void test2() {
		system.out.println("test2");
		test1() 
	}
}

在如上代码中,调用test2方法前会先获取内置锁,然后打印输出。之后调用test1方法,在调用前会先去获取内置锁,如果内置锁不是可重入的,那么调用线程将会一直被阻塞。实际上,synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁没有被任何线程占用。当一个钱程获取了该锁时,计数器的值会变成1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1,当释放锁后计数器值-1。当计数器值为0时,锁里面的线程标示被重置为null,这时候被阻塞的线程会被唤醒来竞争获取该锁。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值