浅谈对synchronized的理解

引言

在并发编程中,经常遇到多个线程访问同一个共享资源,这时候作为开发者必须考虑如何维护数据一致性,在java中synchronized关键字被常用于维护数据一致性。synchronized机制是给共享资源上锁,只有拿到锁的线程才可以访问共享资源,这样就可以强制使得对共享资源的访问都是顺序的,因为对于共享资源属性访问是必要也是必须的,下文会有具体示例演示。

java中的锁

一般在java中所说的锁就是指的内置锁,每个java对象都可以作为一个实现同步的锁,虽然说在java中一切皆对象,但是锁必须是引用类型的,基本数据类型则不可以。每一个引用类型的对象都可以隐式的扮演一个用于同步的锁的角色,执行线程进入synchronized块之前会自动获得锁,无论是通过正常语句退出还是执行过程中抛出了异常,线程都会在放弃对synchronized块的控制时自动释放锁。获得锁的唯一途径就是进入这个内部锁保护的同步块或方法

正如引言中所说,对共享资源的访问必须是顺序的,也就是说当多个线程对共享资源访问的时候,只能有一个线程可以获得该共享资源的锁,当线程A尝试获取线程B的锁时,线程A必须等待或者阻塞,直到线程B释放该锁为止,否则线程A将一直等待下去,因此java内置锁也称作互斥锁,也即是说锁实际上是一种互斥机制。

根据使用方式的不同一般我们会将锁分为对象锁和类锁,两个锁是有很大差别的,对象锁是作用在实例方法或者一个对象实例上面的,而类锁是作用在静态方法或者Class对象上面的。一个类可以有多个实例对象,因此一个类的对象锁可能会有多个,但是每个类只有一个Class对象,所以类锁只有一个。类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定的是实例方法还是静态方法区别的

在java中实现锁机制不仅仅限于使用synchronized关键字,还有JDK1.5之后提供的Lock,Lock不在本文讨论范围之内。一个synchronized块包含两个部分:锁对象的引用,以及这个锁保护的代码块。如果作用在实例方法上面,锁就是该方法所在的当前对象,静态synchronized方法会从Class对象上获得锁。

synchronized使用示例

多窗口售票

假设一个火车票售票系统,有若干个窗口同时售票,很显然在这里票是作为多个窗口的共享资源存在的,由于座位号是确定的,因此票上面的号码也是确定的,我们用多个线程来模拟多个窗口同时售票,首先在不使用synchronized关键字的情况下测试一下售票情况。

先将票本身作为一个共享资源放在单独的线程中,这种作为共享资源存在的线程很显然应该是实现Runnable接口,我们将票的总数num作为一个入参传入,每次生成一个票之后将num做减法运算,直至num为0即停止,说明票已经售完了,然后开启多个线程将票资源传入。

public class Ticket implements Runnable{

	private int num;//票数量
	private boolean flag=true;//若为false则售票停止
	
	public Ticket(int num){
		this.num=num;
	}

	@Override
	public void run() {
		while(flag){
			ticket();
		}
	}
	
	private void ticket(){
		if(num<=0){
			flag=false;
			return;
		}
		try {
			Thread.sleep(20);//模拟延时操作
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		//输出当前窗口号以及出票序列号
		System.out.println(Thread.currentThread().getName()+"售出票序列号:"+num--);
	}
}
public class MainTest {

	public static void main(String[] args) {

		Ticket ticket = new Ticket(5);
		
		Thread window01 = new Thread(ticket, "窗口01");
		Thread window02 = new Thread(ticket, "窗口02");
		Thread window03 = new Thread(ticket, "窗口03");

		window01.start();
		window02.start();
		window03.start();

	}
}

程序的输出结果如下:

窗口02售出票序列号:5
窗口03售出票序列号:4
窗口01售出票序列号:5
窗口02售出票序列号:3
窗口01售出票序列号:2
窗口03售出票序列号:2
窗口02售出票序列号:1
窗口03售出票序列号:0
窗口01售出票序列号:-1

从上面程序运行结果可以看出不但票的序号有重号而且出票数量也不对,这种售票系统比12306可要烂多了,人家在繁忙的时候只是刷不到票而已,而这里的售票系统倒好了,出票比预计的多了而且会出现多个人争抢做同一个座位的风险。如果是单个售票窗口是不会出现这种问题,多窗口同时售票就会出现争抢共享资源因此紊乱的现象,解决该现象也很简单,就是在ticket()方法前面加上synchronized关键字或者将ticket()方法的方法体完全用synchronized块包括起来。

//方式一
private synchronized void ticket(){
	if(num<=0){
		flag=false;
		return;
	}
	try {
		Thread.sleep(20);//模拟延时操作
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	System.out.println(Thread.currentThread().getName()+"售出票序列号:"+num--);
}
//方式二
private void ticket(){
	synchronized (this) {
		if (num <= 0) {
			flag = false;
			return;
		}
		try {
			Thread.sleep(20);//模拟延时操作
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + "售出票序列号:" + num--);
	}
}

再看一下加入synchronized关键字的程序运行结果:

窗口01售出票序列号:5
窗口03售出票序列号:4
窗口03售出票序列号:3
窗口02售出票序列号:2
窗口02售出票序列号:1

从这里可以看出在实例方法上面加上synchronized关键字的实现效果跟对整个方法体加上synchronized效果是一样的。另外一点需要注意加锁的时机也非常重要,本示例中ticket()方法中有两处操作容易出现紊乱,一个是在if语句模块,一处是在num--,这两处操作本身都不是原子类型的操作,但是在使用运行的时候需要这两处当成一个整体操作,所以synchronized将整个方法体都包裹在了一起。如若不然,假设num当前值是1,但是窗口01执行到了num--,整个操作还没执行完成,只进行了赋值运算还没进行自减运算,但是窗口02已经进入到了if语句模块,此时num还是等于1,等到窗口02执行到了输出语句的时候,窗口01的num--也已经将自减运算执行完成,这时候窗口02就会输出序列号0的票。再者如果将synchronized关键字加在了run方法上面,这时候的操作不会出现紊乱或者错误,但是这种加锁方式无异于单窗口操作,当窗口01拿到锁进入run()方法之后,必须等到flag为false才会将语句执行完成跳出循环,这时候的num就已经为0了,也就是说票已经被售卖完了,这种方式摒弃了多线程操作,违背了最初的设计原则-多窗口售票。

懒汉式单例模式

创建单例模式有很多中实现方式,本文只讨论懒汉式创建。在Android开发过程中单例模式可以说是最常使用的一种设计模式,因为它操作简单还可以有效减少内存溢出。下面是懒汉式创建单例模式一个示例:

public class Singleton {

	private static Singleton instance;

	private Singleton() {
	}

	public static Singleton getInstance() {
		if (instance == null) {
			instance = new Singleton();
		}
		return instance;
	}
}

如果对于多窗口售票逻辑已经完全明白了的话就可以看出这里的实现方式是有问题的,我们可以简单的创建几个线程来获取单例输出对象的hascode值。

com.sunny.singleton.Singleton@15c330aa
com.sunny.singleton.Singleton@15c330aa
com.sunny.singleton.Singleton@41aff40f

在多线程模式下发现会出现不同的对象,这种单例模式很显然不是我们想要的,那么根据上面多窗口售票的逻辑我们在getInstance()方法上面加上一个synchronized关键字,给该方法加上锁,加上锁之后可以避免多线程模式下生成多个不同对象,但是同样会带来一个效率问题,因为不管哪个线性进入getInstance()方法都会先获得锁,然后再次释放锁,这是一个方面,另一个方面就是只有在第一次调用getInstance()方法的时候,也就是在if语句块内才会出现多线程并发问题,而我们却索性将整个方法都上锁了。讨论到这里就引出了另外一个问题,究竟是synchronized方法好还是synchronized代码块好呢?有一个原则就是锁的范围越小越好,加锁的目的就是将锁进去的代码作为原子性操作,因为非原子操作都不是线程安全的,因此synchronized代码块应该是在开发过程中优先考虑使用的加锁方式。

public static Singleton getInstance() {
	if (instance == null) {
		synchronized (Singleton.class) {
			instance = new Singleton();
		}
	}
	return instance;
}

这里也会遇到类似上面的问题,多线程并发下回生成多个实例,如线程A和线程B都进入if语句块,假设线程A先获得锁,线程B则等待,当new一个实例后,线程A释放锁,线程B获得锁后会再次执行new语句,同样不能保证单例要求,那么下面代码再来一个null判断,进行双重检查上锁呢?

public static Singleton getInstance() {
	if (instance == null) {
		synchronized (Singleton.class) {
			if(instance==null){
				instance = new Singleton();
			}
		}
	}
	return instance;
}

该模式就是双重检查上锁实现的单例模式,这里在代码层面我们已经基本保证了线程安全了,但是还是有问题的,双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。双重检查锁定失败的问题并不归咎于 JVM 中的实现bug,而是归咎于java平台内存模型。内存模型允许所谓的“无序写入”,这也是这些习语失败的一个主要原因。更为详细的介绍可以参考Java单例模式中双重检查锁的问题 。所以单例模式创建比较建议使用恶汉式创建或者静态内部类方式创建。

synchronized不具有继承性

我们可以通过一个简单的demo验证这个问题,在一个方法中顺序的输出一系列数字,并且输出该数字所在的线程名称,在父类中加上synchronized关键字,子类重写父类方法测试一下加上synchronized关键字和不加关键字的区别即可。

public class Parent {

	public synchronized void test() {
		for (int i = 0; i < 5; i++) {
			System.out.println("Parent " + Thread.currentThread().getName() + ":" + i);
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

子类继承父类Parent,重写test()方法.

public class Child extends Parent {

	@Override
	public void test() {
		for (int i = 0; i < 5; i++) {
			System.out.println("Child " + Thread.currentThread().getName() + ":" + i);
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}

}

测试代码如下:

final Child c = new Child();

new Thread() {
	public void run() {
		c.test();
	};
}.start();

new Thread() {
	public void run() {
		c.test();
	};
}.start();

输出结果如下:

Parent Thread-0:0   Child Thread-0:0
Parent Thread-0:1   Child Thread-1:0
Parent Thread-0:2   Child Thread-0:1
Parent Thread-0:3   Child Thread-1:1
Parent Thread-0:4   Child Thread-0:2
Parent Thread-1:0   Child Thread-1:2
Parent Thread-1:1   Child Thread-0:3
Parent Thread-1:2   Child Thread-1:3
Parent Thread-1:3   Child Thread-0:4
Parent Thread-1:4   Child Thread-1:4

通过输出信息可以知道,父类Parent中会将单个线程中序列号输出完成才会执行另一个线程中代码,但是子类Child中确是两个线程交替输出数字,所以synchronized不具有继承性。

死锁示例

死锁是多线程开发中比较常见的一个问题。若有多个线程访问多个资源时,相互之间存在竞争,就容易出现死锁。下面就是一个死锁的示例,当一个线程等待另一个线程持有的锁时,而另一个线程也在等待该线程锁持有的锁,这时候两个线程都会处于阻塞状态,程序便出现死锁。

public class Thread01 extends Thread{

	private Object resource01;
	private Object resource02;
	
	public Thread01(Object resource01, Object resource02) {
		this.resource01 = resource01;
		this.resource02 = resource02;
	}

	@Override
	public void run() {
		synchronized(resource01){
			System.out.println("Thread01 locked resource01");
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (resource02) {
				System.out.println("Thread01 locked resource02");
			}
			
		}
	}
}
public class Thread02 extends Thread{

	private Object resource01;
	private Object resource02;
	
	public Thread02(Object resource01, Object resource02) {
		this.resource01 = resource01;
		this.resource02 = resource02;
	}

	@Override
	public void run() {
		synchronized(resource02){
			System.out.println("Thread02 locked resource02");
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (resource01) {
				System.out.println("Thread02 locked resource01");
			}
		}
	}
}
public class MainTest {
	
	public static void main(String[] args) {
		
		final Object resource01="resource01";
		final Object resource02="resource02";
		
		Thread01 thread01=new Thread01(resource01, resource02);
		Thread02 thread02=new Thread02(resource01, resource02);
		
		thread01.start();
		thread02.start();
	}

}

执行上面的程序就会一直等待下去,出现死锁。当线程Thread01获得resource01的锁后,等待500ms,然后尝试获取resource02的锁,但是此时resouce02锁已经被Thread02持有,同样Thread02也等待了500ms尝试获取resouce01锁,但是该所已经被Thread01持有,这样两个线程都在等待对方所有的资源,造成了死锁。

其它

关键字synchronized具有锁重入功能,当一个线程已经持有一个对象锁后,再次请求该对象锁时是可以得到该对象的锁的,这种方式是必须的,否则在一个synchronized方法内部就没有办法调用该对象的另外一个synchronized方法了。锁重入是通过为每个所关联一个计数器和一个占有它的线程,当计数器为0时,认为锁是未被占有的。线程请求一个未被占有的锁时,JVM会记录锁的占有者,并将计数器设置为1。如果同一个线程再次请求该锁,计数器会递增,每次占有的线程退出同步代码块时计数器会递减,直至减为0时锁才会被释放。

在声明一个对象作为锁的时候要注意字符串类型锁对象,因为字符串有一个常量池,如果不同的线程持有的锁是具有相同字符的字符串锁时,两个锁实际上同一个锁。

参考资料

Java synchronized 详解

Java中Synchronized的用法

Java SE1.6中的Synchronized

java synchronized类锁,对象锁详解(转载)

深入理解java中的synchronized关键字

Java单例模式中双重检查锁的问题

评论

您确定要删除吗?删除之后不可恢复