Java与锁的一些简单总结

Java与锁的一些简单总结

作者:大飞

 

  • 前言
       从开始写Java到现在,从开始不知道锁是什么,怎么用,更不知道为什么要用。到现在能够在必要的场景下正确的使用一些锁。这过程中经历了对锁的不断尝试和理解,这篇文章就来做一下Java里面关于锁的一些简单的总结。
       有错误的地方请指正,有没提到的内容请补充!
 
  • 在Java中,什么情况下需要使用锁?
       首先我们可以回顾一下之前的编码经历,什么情况下要使用锁呢?抛开那些加锁方法和代码块,我们按照内存分布的方式来理解下,如果多个程序会访问同一块儿内存中的数据,这种情况下 可能需要加锁。这块儿共享内存可以对应到我们Java中的全局变量、共享资源等等,总之就是大家(Java线程)共用的。
 
        先举一个反例,看看下面的方法:
	public static String getString(){
		StringBuilder builder = new StringBuilder();
		builder.append('s');
		builder.append('h');
		builder.append('i');
		builder.append('t');
		return builder.toString();
	}
       首先我们在方法中创建了一个实例,这个实例被分配到Java堆内存中。我们知道在Java内存分布中,栈内存是每个线程各自私有的,而堆内存是所有线程共用的,所以上面程序中的builder对应的对象处于所有线程共用的内存区域中,理论上可能被多个线程访问到。
       但是,仔细思考一下,尽管builder对象被分配到了堆内存,但builder这个引用(类似句柄)只有在当前方法中有效。而且多个线程调用这个方法时,每个线程都会去new一个实例,都会有自己的builder引用(引用存在于线程的栈中),所以对于每个线程来说,只有它自己可以访问在堆内存中分配的builder,所以builder是不共享资源,上面的方法不需要加锁。
       延伸:
       这种情况也称为栈封闭,可以认为是线程安全的,不需要加锁。而且,这种情况下new的对象实例一定会分配在堆内存里面么?JVM中可能采取一些优化手段,比如逃逸分析(Escape Analysis),基于逃逸分析可能会进行栈上分配。也就是说,JVM会检测到,builder这个引用的生命周期只存在于上述方法范围中,不可能逃逸到方法外,所以可能就会直接将builder引用指向的对象分配到栈上了。
 
       上面我们看到的是不存在共享资源的情况下,不需要锁的例子。下面再看一个存在共享资源的例子:
	public static final Map<String, String> MAP;
	
	static{
		Map<String, String> temp = new HashMap<String, String>();
		temp.put("a", "1");
		temp.put("b", "2");
		MAP = Collections.unmodifiableMap(temp);
	}
	
	public static String getName(){
		String px = "tim-";
		return px + MAP.get("a");
	}
       很明显MAP是一个全局的共享资源,但是getName方法仍然不用加锁,为什么呢?因为虽然MAP是共享资源,但是它是不可变的,可能有多个线程访问它,但没有线程会修改它,所以这里也不需要加锁。
       延伸:
       我们会将这种不可变的域定义为常量,也就是说整个程序的运行过程中都不会去修改(写)这个常量。但是,就算是常量也要初始化吧,初始化也是写的过程,那怎么保证在初始化的时候不会有其他Java线程来读这个常量呢?所以一般常量都会由final修饰,可以保证安全发布(也就是说初始化过程中不会被其他线程读到),更多信息可以看下JSR-133中关于final域的重排规则。
 
       最后再看一个例子:
	public static final Map<String, String> MAP_U;
	
	static{
		Map<String, String> temp = new HashMap<String, String>();
		temp.put("a", "1");
		temp.put("b", "2");
		MAP_U = Collections.synchronizedMap(temp);
	}
	
	public static String getNameU(){
		String px = "tim-";
		return px + MAP_U.get("a");
	}
       很明显MAP_U是一个全局的共享资源,而且是可变的,我们不能保证其他线程不去修改MAP_U中的数据,所以访问这个共享数据的时候需要加锁。尽管我们方法中没有显示加锁,但MAP_U是一个线程安全的Map,get方法中已经加锁。
       延伸:
       尽管上面的MAP_U是线程安全的Map,但在某个复合操作下(比如判断没有则添加)还得额外加锁,如果需要原子的复合操作,请参见ConcurrenMap接口中提供的一些原子复合操作。
 
       总结一下:当存在共享资源,且有线程会修改(写)这个共享资源时,那么对这个共享资源的访问(读写)都需要加锁。
 
  • 如果获取锁失败,会有那些行为?
       废话,获取锁失败后当然要等待了。
       但具体怎么等待呢?有哪些细节?
       我们知道Java中有很多锁,具体的等待细节也有所不同,但大体上等待方式可分两种: 自旋等待阻塞等待。从较底层的层面来说,自旋等待相当于当前的程序(进程)还在被调度执行,处理器还会执行程序的指令,但是这些指令表达的意思都是一直在不断(循环)尝试获取锁,直到获取成功,所以自旋等待也称为忙等待;而阻塞等待相当于当前程序不会被调度器调度了,处理器也不在执行它的指令了,一直到其他程序释放了锁,将其唤醒,它才会去再次尝试获取锁。
       延伸:
       Java线程一般都会映射到操作系统的进程,比如在Linux平台,Java线程会映射到Linux的线程(轻量级进程)。在Linux内核中,进程会由调度器来进行调度。这里简单说下CFS调度器,系统中所有可执行的进程在linux内核中组成一棵红黑树,CFS会从红黑树选择进程来调度。当进程阻塞后,会被从红黑树中转移到等待队列中,直到进程被唤醒,再次从等待队列中转移到红黑树中,才有可能再次被调度。
 
       总结一下:如果程序获取锁失败,无外乎两种情况:程序继续被调度执行(不断重试);程序阻塞,不会被调度。
 
  • 具体的锁是什么?
       前面了解了锁的作用和一些行为,那么具体的锁是什么呢?
        我们通过看一些Java中的锁机制来体会一下,首先最常用的就是synchronized关键字。
       synchronized关键字给我们更多的感觉是语法层面的同步,只要有这个关键字,方法或者代码块儿中的代码就是线程安全的。但具体的锁是什么??
       先看两个synchronized例子:
	private Map<String, String> cache = new HashMap<>();
	public synchronized void put(String k, String v){
		cache.put(k, v);
	}
	
	public void putV(String k, String v){
		synchronized (cache) {
			cache.put(k, v);
		}
	}
       其实我们关注的锁,是一个对象,这里就叫锁对象吧。我们知道,用synchronized修饰实例方法的话,就相当于synchronized(this);修饰静态方法的话,就相当于synchronized(this.class)。可见,synchronized相关的代码最后都可以归结为是synchronized(Object)的形式,那么这个Object其实就是锁对象。
       一般锁对象可以是我们要访问的共享资源对象本身,也可以是专门定义的一个锁对象,总之得是一个公共的对象,所有访问其保护资源的线程都能访问到的对象。
       好吧,看看下面这个程序有什么问题:
	public void putVV(String k, String v){
		Object lock = new Object();
		synchronized (lock) {
			cache.put(k, v);
		}
	}
     
 
       其次,ReentrantLock也是比较常用的锁机制。
       相比synchronized关键字,ReentrantLock更容易理解。它本身就是一个锁(锁对象),我们会自然而然的创建好这个锁对象,然后执行加锁解锁等操作,不会像使用synchronized那样有时候不知道自己在干啥。
 
       最后,在Java中有时候会使用一些基于CAS操作的自旋锁机制。
       这些操作其实也是基于对一个数值的CAS等操作来进行加锁解锁过程,这个数值就相当于是锁对象。
 
       总结一下:具体的锁可以看成是一个对象,这个对象会被能访问由锁保护资源的所有线程访问到。
 
  • Java中提供了哪些锁?
       synchronized:
       内置锁,可重入。内部做了一些细致的优化,获取锁过程为:偏向锁->轻量级锁->自旋锁->重量级锁。
       ReentrantLock:
       基于AQS的可重入锁,比synchronized更加灵活。
        ReentrantReadWriteLock:
       基于AQS的可重入的读写锁。
        SequenceLock:
       基于AQS的可重入的顺序锁,在乐观读方法上要比ReentrantReadWriteLock高效一些。(这个类在jsr166e的extra包里发现,但貌似没出现在jdk里)
       StampedLock:
       不可重入的优化读写锁,针对乐观读做了优化,一般用于构建内部并发组件。
       cas:
       程序中可以通过CAS操作来构建一些锁,比如jdk1.7中ForkJoin框架中使用的scanGuard包含的顺序锁、jdk1.8中Striped64的cellsBusy锁等。
        基于AQS构建的同步机制:
       这些同步机制和锁机制也有着很多联系。
 
       总结一下:Java中提供了各种各样的锁,合适的场景使用合适的锁。
 
  • 使用锁有哪些注意事项?
       1.正确的使用锁。
          该用的时候用,不该用的时候不用。不要因为确定不了是否会出现并发问题,就把所有的方法都加上锁,要仔细分析可能出现并发的地方,在需要的时候加锁;也不要意识不到并发问题,让一些被共享的资源在无锁保护下裸奔,常见的比如使用一个公共的Random实例。
       2.使用合适的锁。
          前面我们简单介绍了那么多锁,在实际使用时要使用合适的锁。
       3.锁的一些优化。
          尽量减少锁的范围,值锁可能产生并发问题的代码;尽量减少锁的粒度,避免一些不必要的竞争,比如ConcurrentHashMap中的方式;按照实际情况控制锁行为,比如实际等待锁的时间很短(就是说等待时间比上下文切换时间还短),就没有必要阻塞,可以自旋一下。如果自旋超过一定次数,都让其进入阻塞状态,避免消耗过多的处理器资源。
 
  • JVM在这方面做了哪些优化?
       1.锁消除。
       看个例子:
	public static String getStr(){
		StringBuffer buffer = new StringBuffer();
		buffer.append("1");
		buffer.append("2");
		buffer.append("3");
		return buffer.toString();
	}
       前面已经提到过,这种情况属于栈封闭。同时我们知道,StringBuffer是一个线程安全的类,所有方法都是同步的。但很明显,这里的同步都是不必要的,所以JVM很可能会将代码中产生同步相关指令消除掉。
 
       2.锁粗化。
       看个例子:
	public static StringBuffer getStr(){
		StringBuffer buffer = new StringBuffer();
		buffer.append("1");
		buffer.append("2");
		buffer.append("3");
		return buffer;
	}
       这种情况下,很明显buffer已经逃逸到方法外,没办法进行锁消除。但里面的3个append方法都会各自加锁解锁,实际上加一次锁(包含3个append)也可以,所以JVM很可能会将代码中的3个加锁解锁操作合并成一个。
 
       3.偏向锁。
       简单的说,就是在JVM内部,如果一个对象作为synchronized的锁对象,当一个线程获取这个锁时,会将线程id保存到这个锁对象的对象头上,当紧接着下一次申请获取这个锁的线程还是之前的线程时,只需要比较对象头中的线程id,不需要做其他锁相关的操作了。
 
 
       OK!简单总结到这里。  

猜你喜欢

转载自brokendreams.iteye.com/blog/2262328