监视器
java中同步是通过监视器模型来实现的,JAVA中的监视器实际是一个代码块,这段代码块同一时刻只允许被一个线程执行。线程要想执行这段代码块的唯一方式是获得监视器。
监视器有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。什么时候需要协作?比如:一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器并不是这个通知线程刚释放的监视器,等待线程会继续等待。object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。
如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。
注意:当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。
对象锁
JVM中的一些数据,比如堆和方法区会被所有线程共享。JAVA中每个对象和类实际上都一把锁与之相关联,对于对象来说,监视的是这个对象的实例变量,对于类来说,监视的是类变量,如果一个对象没有实例变量,就什么也不监视。当虚拟机装载类时,会创建一个Class类的实例,锁住一个类实际上锁住的是这个类对应的Class类的实例。对象锁是可重入的,也就是说对一个对象或者类上的锁可以累加。
在JAVA中有两种监视区域:同步方法和同步块,这两种监视区域都和一个引入对象相关联,当到达这个监视区域时,JVM就会锁住这个引用对象,不论它是怎么离开的,都会释放这个引用对象上的锁。JAVA程序员不能自己加对象锁,对象锁是JVM内部机制,只需要编写同步方法或者同步块即可,操作监视区域时JVM会自动帮你上锁或者释放锁。
同步语句
要建立一个同步语句,只需要在相关语句加上synchronized关键字就可以,例如下面的incr方法,如果没有获得当前对象(this)的锁,在同步块内的语句是不会执行的,如果不是this引用,而是用另一个对象的引用,需要获得对应对象的锁同步块才会执行,如果用表达式获得对Class对象实例的引用,就需要锁住那个类。
- void incr() {
- synchronized (this) {
- i++;
- }
- }
以下是incr方法生成的字节码序列:
- void incr();
- Code:
- 0: aload_0 //将this引用压栈
- 1: dup //复制栈顶元素
- 2: astore_1 //出栈并将this引用存放在局部变量1中
- 3: monitorenter //出栈并获取对象锁
- 4: aload_0 //将this引用压栈
- 5: dup //复制栈顶元素
- 6: getfield #17 //获取i的值
- 9: iconst_1 //常数1入栈
- 10: iadd //将i+1的结果入栈
- 11: putfield #17 //将i的值存入this中
- 14: aload_1 //将this引用压栈
- 15: monitorexit //弹出this引用释放对象锁
- 16: goto 22 //返回
- 19: aload_1 //19-22如果抛出,释放对象锁
- 20: monitorexit
- 21: athrow
- 22: return
- Exception table:
- from to target type
- 4 16 19 any
- 19 21 19 any
字节码的第3行从栈顶中获取对象锁,对象锁获取成功后才后执行后面的add操作,第15行释放获取的对象锁。注意:字节码中出现了异常表,是用于确保加锁的对象被释放,即使从同步语句块中抛出异常,也会释放对象锁,不然有可能导致死锁。
同步方法
要建立同步方法,只需要在方法修饰符前加上synchronized关键字,类似代码如下:
- synchronized void incr() {
- i++;
- }
- synchronized void incr();
- Code:
- 0: aload_0 //this引用压栈
- 1: dup //复制栈顶元素
- 2: getfield #2 //获取i的值
- 5: iconst_1 //将常量1入栈
- 6: iadd //i+1入栈
- 7: putfield #2 //将i的值存入this中
- 10: return //返回
可见,JVM并没有使用moniterenter和moniterexit等指令,查看class文件在方法表中可以看到有0020出现,这是incr方法的访问标志(access flag):ACC_SYNCHRONIZED,顾名思义在说incr是一个线程同步方法。当JVM发现这是一个同步方法时,就会在这个对象或者类上获取锁,退出方法时会释放这个锁。两段字段码除了调用指令不同,还有一个区别是同步方法可以没有异常表,实际上JVM隐式地做了异常处理。
优缺点:
synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛地使用。
synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,不过这种抢占的方式可以预防饥饿。
synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。