引言:
很多大型系统为了处理大并发请求时,为了实现业务与服务解耦,通常会使用消息队列机制来提供相应的服务,这是典型的生产者-消费者模式,这里就涉及并发应用时多线程同步问题,临界资源的访问问题。为了更好的理解和应用多线程,我们需要对线程同步机制有深入的认识。
本文将从以下几个方面入手
1)线程同步互斥的概念
2)操作系统底层提供的同步互斥机制
3) java同步机制synchronized,lock
一.线程同步互斥的概念
1.问题引入
先用实例来感受一下线程同步互斥问题
例如,有两个线程P1和P2共享一个变量count,P1或P2的功能是,每执行完某些操作后,将count的值取出加1,R1和R2是工作寄存器。
1)当两个进程按下述顺序执行时:
P1:操作序列;
Rl=count;
R1=R1+1;
count=R1;
P2:操作序列;
R2=count;
R2=R2+1;
count=R2;
其结果使count的值增加了2
2)倘若P1和P2按另一种顺序执行,例如
P1:R1=count;
P2:R2=count;
P1:Rl=Rl+1;count=Rl;
P2:R2=R2+1;count=R2;
按此执行序列,虽使P1和P2都各自对count做了加1操作,但最后的count值却只增加了1,即出现了结果不确定的错误。
显然这种错误与执行顺序有关,又叫与时间相关的错误。之所以出现这种错误,是由于变量count是临界资源,P1和P2不能同时使用,即仅当线程P1对count进行修改并退出后,才允许线程P2访问和修改,那么就可以避免上述的错误结果。
2.相关概念
1).临界资源
每个进程(线程)中访问临界资源的那段代码称为临界区。临界区内的数据一次只能同时被一个进程(线程)使用,当一个进程(线程)使用临界区内的数据时,其他需要使用临界区数据的进程进入等待状态。
注:属于临界资源的硬件有打印机、磁带机等,软件有消息缓冲队列、变量、数组、缓冲区等。
2).互斥
所谓互斥,就是不同线程通过竞争进入临界区(共享的数据和硬件资源),为了防止访问冲突,在有限的时间内只允许其中之一独占性的使用共享资源。如不允许同时写
3).同步
同步关系则是多个线程彼此合作,通过一定的逻辑关系来共同完成一个任务。一般来说,同步关系中往往包含互斥,同时对临界区的资源会按照某种逻辑顺序进行访问。
4).同步与互斥的区别于联系
两者的联系就是:一般同步关系中往往包含互斥
两者的区别就是:互斥是通过竞争对资源的独占使用,彼此之间不需要知道对方的存在,执行顺序是一个乱序。同步是协调多个相互关联线程合作完成任务,彼此之间知道对方存在,执行顺序往往是有序的。
二.操作系统底层提供的同步互斥机制
在多道程序环境下,操作系统如何实现进程(线程)之间的同步和互斥显得极为重要。
荷兰学者Dijkstra给出了一种解决并发进程间互斥与同步关系的通用方法,即信号量机制。他定义了一种名为“信号量”的变量,并且规定在这种变量上只能做所谓的P 操作和 V操作。现在,信号量机制已经被广泛地应用于单处理机和多处理机系统以及计算机网络中。信号量是操作系统提供的一种协调共享资源访问的方法。
1.信号量Semaphore
1)作用:
信号量,有时也称为信号灯,是在多线程环境下使用的一种设施, 它负责协调各个线程, 以保证它们能够正确、合理的使用公共资源。
2)信号量分类:
信号量可以分为几类:
信号量类型 | 信号量定义 |
---|---|
二进制信号量 | 只允许信号量取0或1值,其同时只能被一个线程获取。 |
整型信号量 | 信号量取值是整数,它可以被多个线程同时获得,直到信号量的值变为0。 |
记录型信号量 | 每个信号量s除一个整数值value(计数)外,还有一个等待队列List,其中是阻塞在该信号量的各个线程的标识。 |
3)信号量操作(最重要的部分)
信号量定义如下:
int value; //整型变量
List<Process>; //与该信号量相关联的队列
信号量是一个具有非负初值的整型变量,并且有一个队列与它关联。(队列存放关于该信号量的阻塞进程)因此,定义一个信号量时,要给出它的初值,并给出与它相关的队列指针。信号量除初始化外,仅能通过P、V 两个操作来访问,这两个操作都由原语组成,即在执行过程中不可被中断,也就是说,当一个进程在修改某个信号量时, 没有其他进程可同时对该信号量进行修改。
P(S)操作可描述为:
procedure P(S):
var S:semaphore;//信号量
begin
S.value:=S.value-1;//信号量的值减 1
if S.value<0 then block(S,L)
//若信号量的值小于0,则阻塞执行该P操作的进程(线程)
end
当执行 P(S)操作时,信号量S的值减 1,如果S≥0,表示可以继续执行;如果S<0,表示该进程只能进入S信号量的阻塞队列中等待,由调度程序重新调度其他进程执行。需要注意的是,使该信号量S的值增加的进程会将该阻塞进程唤醒,该进程一旦获得处理机,就可以直接进入临界区,无需再执行 P(S)操作。
V(S)操作可描述为:
procedure V(S):
var S: semaphore;
begin
S.value:=S.value+1;
if S.value≤0
then wakeup(S,L);
//若信号量的值小于等于0,则唤醒等待该信号量的进程(线程)
end
当执行 V(S)操作时,信号量S的值加 1,如果S≤0,则唤醒S信号量阻塞队列队首的阻塞进程,将其状态从阻塞状态转变为就绪状态,执行V操作的进程继续执行;如果S>0,则说明没有进程在该信号量的阻塞队列当中,因此,无需唤醒其他进程,该进程继续执行。
Semaphore可以被抽象为五个操作:
操作 | 说明 |
---|---|
创建 | 创建信号量 |
等待 | 线程等待信号量,如果值大于0,则获得,值减一;如果只等于0,则一直线程进入睡眠状态,知道信号量值大于0或者超时。 |
释放 | 执行释放信号量,则值加一;如果此时有正在等待的线程,则唤醒该线程。 |
试图等待 | 如果调用试图等待,线程并不真正的去获得信号量,还是检查信号量是否能够被获得,如果信号量值大于0,则试图等待返回成功;否则返回失败。 |
销毁 | 销毁信号量 |
2.互斥量(Mutex)
1)定义:
互斥量表现互斥现象的数据结构,也被当作二元信号灯。一个互斥基本上是一个多任务敏感的二元信号,它能用作同步多任务的行为,它常用作保护从中断来的临界段代码并且在共享同步使用的资源。
2)Mutex动作:
操作 | 说明 |
---|---|
创建 | 创建互斥量 |
加锁 | 对资源加锁 |
解锁 | 释放对资源的占有 |
销毁 | 释放互斥量 |
Mutex本质上说就是一把锁,提供对资源的独占访问,所以Mutex主要的作用是用于互斥。Mutex对象的值,只有0和1两个值。这两个值也分别代表了Mutex的两种状态。值为0, 表示锁定状态,当前对象被锁定,用户进程/线程如果试图Lock临界资源,则进入排队等待;值为1,表示空闲状态,当前对象为空闲,用户进程/线程可以Lock临界资源,之后Mutex值减1变为0。
三.java同步机制synchronized,lock
1.synchronized同步
synchronized是Java中的关键字,是一种粗粒度同步锁。它修饰的对象一般有以下几种:
1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
3. 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
同步方法
其实这个还有一个同步方法,也就是用关键字synchronized来修饰一个方法,对于关键字synchronized修饰的方法,就不需要再指定同步监视器了,因为同步方法的同步监视器就是this,也就是调用了该方法的对象。
public synchronized void testThread()
{
//需要执行的代码块
}
这里同步监视器的锁是对象,这样会导致针对该方法的访问是互斥的, 这样会大大降低程序性能。
同步代码块
synchronized(obj)//这里的obj就相当于锁,可以是任意对象
{
同步代码块
}
上面就是同步的代码块方式,也就是说,当线程开始执行同步代码块之前,必须先获得对同步监视器的锁定,而且无论在什么时候,只能有一个线程可以获得对同步监视器的锁定,当同步代码执行完成之后,这个线程会释放该线程的同步监视器的锁定,同步代码块比同步方法的作用范围要小,性能稍优于同步方法。
使用synchronized 代码块相比方法有两点优势:
1、可以只对需要同步的使用
2、与wait()/notify()/nitifyAll()一起使用时,比较方便
synchronized与wait(),notify()
synchronized与wait()和notify()协作完成线程同步,wait(),notify()等方法是属于object,而非线程类的。所以这些方法只能作用于同步监视器的锁上。
以下都是Object的方法,并不是线程的方法!
wait():释放占有的对象锁,线程进入等待池,释放cpu,而其他正在等待的线程即可抢占此锁,获得锁的线程即可运行程序。
sleep()不同的是,线程调用此方法后,会休眠一段时间,休眠期间,会暂时释放cpu,但并不释放对象锁。也就是说,在休眠期间,其他线程依然无法进入此代码内部。休眠结束,线程重新获得cpu,执行代码。wait()和sleep()最大的不同在于wait()会释放对象锁,而sleep()不会!
notify(): 该方法会唤醒因为调用对象的wait()而等待的线程,其实就是对对象锁的唤醒,从而使得wait()的线程可以有机会获取对象锁。调用notify()后,并不会立即释放锁,而是继续执行当前代码,直到synchronized中的代码全部执行完毕,才会释放对象锁。JVM则会在等待的线程中调度一个线程去获得对象锁,执行代码。需要注意的是wait()和notify()必须在synchronized代码块中调用。
2.Lock机制
Lock与synchronized相比有更精确的线程语义和更好的性能。synchronzied会自动释放锁,而Lock要求程序员手工释放。
Lock提供了比synchronized更加广泛的锁定操作,能够实现更灵活的结构,而且支持多个condition对象。
在实现线程安全的控制中,比较常用的是ReentrantLock,使用该对象可以显式的加锁和释放锁,格式如下:
class A
{
private final ReentrantLock lock=new ReentrantLock();
public void b()
{
lock.lock();
try{
//需要执行的代码块
}catch(Exception e){
e.printStackTrace();
}finally{
lock.unlock();
}
}
}
为了确保能够在必要的时候释放锁,所以上面的代码中使用finally来确保锁的释放。
读写锁ReadWriteLock
例如一个类对其内部共享数据data提供了get()和set()方法,如果用synchronized,则代码如下:
class syncData {
private int data;// 共享数据
public synchronized void set(int data) {
System.out.println(Thread.currentThread().getName() + "准备写入数据");
this.data = data;
System.out.println(Thread.currentThread().getName() + "写入" + this.data);
}
public synchronized void get() {
System.out.println(Thread.currentThread().getName() + "准备读取数据");
System.out.println(Thread.currentThread().getName() + "读取" + this.data);
}
}
但是,我们发现读写线程是互不干扰,读取线程之间应该是不干扰的。但是上述的synchronized没办法做到这点。
我们可以用读写锁ReadWriteLock实现:
class Data {
private int data;// 共享数据
private ReadWriteLock rwl = new ReentrantReadWriteLock();
public void set(int data) {
rwl.writeLock().lock();// 取到写锁
try {
System.out.println(Thread.currentThread().getName() + "准备写入数据");
this.data = data; System.out.println(Thread.currentThread().getName() + "写入" + this.data);
} finally {
rwl.writeLock().unlock();// 释放写锁
}
}
public void get() {
rwl.readLock().lock();// 取到读锁
try { System.out.println(Thread.currentThread().getName() + "准备读取数据"); System.out.println(Thread.currentThread().getName() + "读取" + this.data);
} finally {
rwl.readLock().unlock();// 释放读锁
}
}
}
从理论上讲,与互斥锁定相比,使用读-写锁定所允许的并发性增强将带来更大的性能提高。
线程间通信Condition
Condition可以替代传统的线程间通信,用await()替换wait(),用signal()替换notify(),用signalAll()替换notifyAll()。
注:为什么方法名不直接叫wait()/notify()/nofityAll()?因为Object的这几个方法是final的,不可重写!
注意,Condition是被绑定到Lock上的,要创建一个Lock的Condition必须用newCondition()方法。
Condition的强大之处在于它可以为多个线程间建立不同的Condition。
看JDK文档中的一个例子:假定有一个绑定的缓冲区,它支持 put 和 take 方法。如果试图在空的缓冲区上执行 take 操作,则在某一个项变得可用之前,线程将一直阻塞;如果试图在满的缓冲区上执行 put 操作,则在有空间变得可用之前,线程将一直阻塞。我们喜欢在单独的等待 set 中保存put 线程和take 线程,这样就可以在缓冲区中的项或空间变得可用时利用最佳规划,一次只通知一个线程。可以使用两个Condition 实例来做到这一点。(其实就是java.util.concurrent.ArrayBlockingQueue的功能)
class BoundedBuffer {
final Lock lock = new ReentrantLock(); //锁对象
final Condition notFull = lock.newCondition(); //写线程锁
final Condition notEmpty = lock.newCondition(); //读线程锁
final Object[] items = new Object[100];//缓存队列
int putptr; //写索引
int takeptr; //读索引
int count; //队列中数据数目
//写
public void put(Object x) throws InterruptedException {
lock.lock(); //锁定
try {
// 如果队列满,则阻塞<写线程>
while (count == items.length) {
notFull.await();
}
// 写入队列,并更新写索引
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
++count;
// 唤醒<读线程>
notEmpty.signal();
} finally {
lock.unlock();//解除锁定
}
}
//读
public Object take() throws InterruptedException {
lock.lock(); //锁定
try {
// 如果队列空,则阻塞<读线程>
while (count == 0) {
notEmpty.await();
}
//读取队列,并更新读索引
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
--count;
// 唤醒<写线程>
notFull.signal();
return x;
} finally {
lock.unlock();//解除锁定
}
}
优点:
假设缓存队列中已经存满,那么阻塞的肯定是写线程,唤醒的肯定是读线程,相反,阻塞的肯定是读线程,唤醒的肯定是写线程。
那么假设只有一个Condition会有什么效果呢?缓存队列中已经存满,这个Lock不知道唤醒的是读线程还是写线程了,如果唤醒的是读线程,皆大欢喜,如果唤醒的是写线程,那么线程刚被唤醒,又被阻塞了,这时又去唤醒,这样就浪费了很多时间。