JAVA总结(四)----- 线程(二)

注:以下程序和概念均参考自《java编程思想》、《Effective Java》、《java并发编程实战》

目录

一、资源竞争导致线程安全性问题

①、什么是资源竞争

②、线程安全性

③、解决资源竞争问题

二、java锁机制

①、同步代码块

②、内置锁的可重入性

③、内存可见性

三、java同步的简单使用

①、内置锁的同步方法

②、内置锁的同步代码块

③、volatile变量的同步机制

④、原子变量与非阻塞的同步机制

⑤、显示锁的同步机制


 

一、资源竞争导致线程安全性问题

①、什么是资源竞争

      当多个线程对共享资源进行读写访问时,由于没有限制将会造成资源的误读,误写。比如说当你坐在桌边手拿筷子,正要吃盘子中最后一片食物时,但这片食物却突然消失了,因为你的线程别挂起了。另一个进餐者进入并吃到了它。

②、线程安全性

      编写线程安全的代码,其核心在于要对状态(所谓状态指存储在对象中的数据)访问操作进行管理,特别是对共享的和可变状态的访问。 “共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。

     线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

     正确的行为:所谓正确的行为指类的行为与其规范完全一致。

③、解决资源竞争问题

     在上述已经说明了,由于资源竞争必然导致类不具备正确的行为(我们希望预期的效果)也就导致线程安全性问题。由于状态存储在对象中。所以我们需要对对象进行同步(或通过加锁、或通过使用volatile类型的变量、或通过使用原子变量)。


二、java锁机制

①、同步代码块

     java提供一种内置的锁机制来支持原子性:“同步代码块”。同步代码块包括两部分:一个作为由这个锁保护的代码块,一个作为锁的对象引用。以synchronized关键字来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

     每个java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或者监视器锁。线程在进入同步代码块之前会自动获得锁(获得锁的引用),并且在退出同步代码块时自动释放锁,无论是什么途径退出同步代码块(正常,异常等等)。获取锁的方式都只有进入这个由这个锁保护的同步代码块。

     java内置锁相当于一种互斥锁,这意味着最多只有一种线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。

②、内置锁的可重入性

     由于内置锁可重入,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功(也就是说,在同步代码块中调用同一把锁的其它同步代码块)。“重入”意味着获取锁的操作的粒度是线程,而不是“调用”。

     重入的实现方法:每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为没有任何线程持有。当线程请求一个未被持有的锁时,JVM将记录下锁的持有者,并且将获取计数值置为1,。如果同一个线程再次获取这个锁(重入)技术值将递增,而当线程退出同步代码块时,计数值会相应的递减。当值为0,这个锁就会被释放。

③、内存可见性

     在多线程环境下,对某个共享变量的先写入值,然后在读取它,我们无法确保我们看到的是相同的值(线程安全性问题)。为了确保多个线程之间对内存写入操作的可见性必须使用同步机制。

     内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,如图所示。当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁释放之前,A看到的变量值在B获取锁后同样可以由B看到。

JAVA总结(四)----- 线程(二)_第1张图片

     加锁不仅只是局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行操作或者写操作的线程都必须在同一个锁上同步。


三、java同步的简单使用

①、内置锁的同步方法

     采用sychronized关键字加在方法上使方法变成同步代码块。当有线程进入该方法时将会向之前所说的首先获取该方法对象的锁,若无法获取则进入阻塞状态,直到获取。

public class StopThread {

	private static boolean stopRequested;
	
	public static synchronized void requestStop() {
		stopRequested = true;
	}
	
	public static synchronized boolean isStopRequested() {
		return stopRequested;
	}
	
	public static void main(String[] args) throws InterruptedException {
		Thread backgroundThread = new Thread(new Runnable() {

			@Override
			public void run() {
				int i = 0;
				while(!isStopRequested())
					i++;
			}
		});
		backgroundThread.start();
		
		TimeUnit.SECONDS.sleep(1);
		requestStop();
	}
}

在这个程序中,如果没有加同步方法的话,在main线程设置stopRequested = true将导致backgroundThread线程无法看到修改,这是因为内存可见性的问题。

②、内置锁的同步代码块

     通过同步代码块的方式,将想要的同步的代码分离出来而不是直接访问整个方法,提高了性能,减少了代码的数量。同步代码块也称作临界区。

格式:

synchronized(syncObject){

// 同步代码部分

}

     同步代码块获取的是syncObject对象上的锁,而不是同步方法那样获取的是调用方法对象的上锁(静态方法获取的是目标类的Class对象的锁)。这也就说同步代码块可以指定获取某个对象的锁。

class DualSynch {
	
	private Object syncObject = new Object();
	
	public synchronized void f() {
		for(int i = 0; i < 5; i++) {
			System.out.println("f()");
			Thread.yield();
		}
	}
	
	public void g() {
		synchronized(syncObject) {
			for(int i = 0; i < 5; i++) {
				System.out.println("g()");
				Thread.yield();				
			}
		}
	}
	
	public synchronized void h() {
		for(int i = 0; i < 5; i++) {
			System.out.println("h()");
			Thread.yield();				
		}
	}
}

// 创建一个线程运行f()方法
// main线程运行g()方法
public class SyncObject {

	public static void main(String[] args) {
		final DualSynch ds = new DualSynch();
		new Thread() {

			@Override
			public void run() {
				ds.f();
			}
			
		}.start();
		ds.g();
	}
}

/**
 * 输出得到的事实是,两个方法在相互输出。并没有出现全部输出完一个才输出另外一个
 * 这主要是因为两个方法同步的不同,同步控制方法中得到当前对象实例的锁,而同步控制快中得到指定对象锁(若是指定当前对象实例,同样其他的同步方法也不能运行)
 * */

③、volatile变量的同步机制

     引入volatile变量是为了解决内存可见性。它是一种稍弱的同步机制,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

     理解volatile变量:当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取volatile变量后,对B也是可见的。因此,写入volatile变量相当于退出同步代码块,读取volatile变量相对于进入同步代码块。所以,volatile变量是比synchronized关键字更轻量级的同步机制。

     volatile的限制有两点。其一是:一个域的值依赖于它之前的值(例如递增操作)。其二是:如果某个域的值受到其他域的值限制,比如说边界条件,lower<=upper(如果lower是volatile变量)。这是因为无法进行更新操作时还要维持不变性。

public class StopThread {

	private volatile static boolean stopRequested;
	
	public static void main(String[] args) throws InterruptedException {
		Thread backgroundThread = new Thread(new Runnable() {

			@Override
			public void run() {
				int i = 0;
				while(!stopRequested)
					i++;
			}
		});
		backgroundThread.start();
		
		TimeUnit.SECONDS.sleep(1);
		stopRequested = true;
	}
}

④、原子变量与非阻塞的同步机制

1、原子性

     原子性表示一个操作要么全部执行成功,要么全部执行失败。java语言规范明确保证对读或者写一个变量是原子的,除非这个变量的类型为long或者double。尽管java中是这样保证的,但还是要避免盲目多线程中只使用原子数据,而不使用同步。因为这里面还涉及可见性的问题。此外,java的自增操作并不是原子操作,自增操作在JVM字节码指令将会翻译成,首先读取它的值,然后放入新的值(即使它+1),最后返回新值。即使变量是原子数据,但并不意味着它使用的所有操作都是原子操作。

2、原子变量

     原子变量比锁的粒度更细,量级更轻。在使用基于原子变量而非锁的算法中,线程在执行时更不易出现延迟,而且如果遇到竞争,也更容易恢复过来。

     原子变量是一种“更好的Volatile”,原子变量类相当于一种泛华的volatile变量。能够支持原子性和有条件的读-改-写操作,它是硬件级别上的原子性。比如说:“AtomicInteger”表示一个int类型的值,并提供了get和set方法,这些Volatile类型的变量(原子类里面存在volatile类型保存value)在读取和写入上有着相同的语义。它还提供一个原子的compareAndSet方法(如果该方法成功执行,那么将实现与读取/写入一个volatile变量相同的内存效果),以及原子的添加、递增和递减等方法。

// 使用原子类获得处理器机器级别的原子操作,因此数字加和返回操作都是原子级别的,不会产生线程读取错误的问题
public class AtomicIntegerTest implements Runnable {
	// 原子类
	private AtomicInteger i = new AtomicInteger(0);
	public int getValue() {
		return i.get();
	}
	
	private void evenIncrement() {
		i.addAndGet(2);// 相当于 i++;i++ 操作
	}
	
	@Override
	public void run() {
		while(true)
			evenIncrement();
	}
	
	public static void main(String[] args) {
		// 5秒后程序自动终止
		new Timer().schedule(new TimerTask() {

			@Override
			public void run() {
				System.err.println("Aborting");
				System.exit(0);
			}
			
		}, 5000);
		
		ExecutorService exec = Executors.newCachedThreadPool();
		AtomicIntegerTest ait = new AtomicIntegerTest();
		for(int i = 0; i < 5; i ++)
			exec.execute(ait);
		
		while(true) {
			int val = ait.getValue();
			if(val % 2 != 0) {
				System.out.println(val);
				System.exit(0);
			}
		}
	}
}

这个程序使用了原子类,并定时5秒终后结束。在主线程中创建5个线程执行递增操作,然后判断value是否为2的倍数(因为递增加值为2) 若不为2的倍数,则程序将结束。很明显这个程序并没有使用锁机制,而只是原子操作,程序最终也只会在定时的5秒后结束。

3、CAS

     互斥锁是一种悲观锁技术,它总是假设了最坏的情况(如果你不进行加锁,那么这个数据一定会被修改),并且在没有其他线程妨碍中才能继续下去。这也就造成3个问题:①系统开销大,②优先级反转,③锁不释放。

     CAS使用乐观锁技术,是一种非阻塞同步的实现。这种方法借助冲突检查机制来判断在更新过程总是否存在来自其他线程的干扰,如果存在,这个操作将失败,并且可以重试(也可以不重试)。在CAS中包含3个操作数——需要读写的内存位置V,进行比较的值A和拟写入的值V。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。

     CAS主要缺点是,它将使调用者处理竞争问题(通过重试,回退,放弃),而在锁中能够自动处理竞争问题(线程在获得锁之前一直阻塞)。

// 模拟CAS操作
public class SimulatedCAS {

	private int value;
	
	public synchronized int get() {
		return value;
	}
	
	public synchronized int compareAndSwap(int expectedValue, int newValue) {
		int oldValue = value;
		if(oldValue == expectedValue)
			value = newValue;
		return oldValue;
	}
	
	public synchronized boolean compareAndSet(int expectedValue, int newValue) {
		return (expectedValue == compareAndSwap(expectedValue, newValue));
	}
}

// 这是一个非阻塞的计数器,使用了CAS操作的计数器。
public class CASConter {

	private SimulatedCAS value = new SimulatedCAS();
	
	public int getValue() {
		return value.get();
	}
	
	public int increment() {
		int v;
		do {
			v = value.get();
			//  当线程发现当前值与value的值不同时(来自其他线程的干扰)然后线程在继续重试。直到相等。
		} while(v != value.compareAndSwap(v, v + 1));
		return v + 1;
	}
	
	public static void main(String[] args) throws InterruptedException {
		ExecutorService exec = Executors.newCachedThreadPool();
		CASConter conter = new CASConter();
		for(int i = 0; i < 5; i++)
			exec.execute(new Runnable() {

				@Override
				public void run() {
					try {
						while(true) {
							System.out.println(Thread.currentThread().getName() + " " + conter.increment());
							TimeUnit.MILLISECONDS.sleep(500);
						}
					} catch(InterruptedException e) {
						System.out.println("Interrupted");
					}
				}
			});
		
		TimeUnit.SECONDS.sleep(3);
		exec.shutdownNow();
	}
}

CASConter不会阻塞,但是当其他线程通信更新计数器,那么会多次执行重试操作(也可以使用原子类)。初看,基于CAS的计数器的性能似乎比基于锁的计数器低。但实际上,如果竞争程度不高,CAS的计数器性能要远优于基于锁的计数器(因为所要进行挂起恢复等系统开销)。

⑤、显示锁的同步机制

自从java5.0之后增加了新的同步机制:ReentrantLock。与synchronized和volatile变量、原子变量作为同步机制的一种。

ReentrantLock实现了java.concurrent.locks.Lock接口。Lock与内置加锁机制不同的是,它提供了一种无条件、可轮询、定时以及可中断的锁获取操作。所有加锁和解锁的方法都是显示的。ReentrantLock与sychronized具有相同的互斥性和内存可见性,以及锁的可重入性,它具有Lock支持的锁获取模式,并且处理锁的可不用问题提供了更高的灵活性。

采用新的加锁机制是为了解决三个内置锁产生的问题:1、无法中断一个正在等待获取锁的线程,2、无法在请求获取一个锁时无限等待下去,3、无法实现非阻塞结构的加锁规则。

采用显示锁时,必须要在finally块中释放锁,这是因为,避免被保护的代码抛出异常,导致锁无法释放。因此使用显示加锁必须像下面这种形式:

lock.lock()

try {

// 保护的代码块,可能抛出异常

} finally {

lock.unlock();

}

使用ReentrantLock更像是一颗定时炸弹,因为你会忘记在finally块中释放锁,所以它比起内置锁更加危险。所以,我们是该怎么选择同步加锁?内置锁更为开发人员熟悉,并且结构紧凑。而显示锁包含了内置锁所有机制,并且还提供了可轮询、可定时、可中断的锁获取操作。对于性能方面,显示锁也是要优于内置锁。但是内置锁是JVM的内置属性并且可以执行一些优化,而显示锁仅仅是类库的实现,并且可能包含一颗定时炸弹。所以,显示锁是一种更为高级的并发工具,我们应该在需要它时在使用它或许能得到意想不到的效果。

 

你可能感兴趣的:(线程安全,内置锁,同步)