阻塞同步:synchronized——重量级锁
关键字:synchronized、重量级锁、锁对象、monitorenter、monitorexit
在Java中,我们经常使用synchronized关键字来做互斥同步以解决多线程并发访问共享数据的问题。synchronized关键字在编译后,会在synchronized所包含的同步代码块前后分别加入monitorenter呵monitorexit这两个字节码指令。synchronized关键字需要制定一个对象来进行加锁和解锁。例如:
Public class Main {
Private static final Object LOCK = new Object();
Public static void method1(){
Synchronized (LOCK){
// do something
}
}
Public static void method2(){
Synchronized(LOCK){
// do something
}
}
}
在没有明确指定该对象时,根据 synchronized 修饰的是实力方法还是静态方法,从而决定是采用对象实例或者类的 class 实例作为锁对象。例如:
Public class SynchronizedTest {
Public synchronized void doSomething(){
// do something
}
}
Public class SynchronizedTest {
Public static synchronized void doSomething(){
// do something
}
}
由于基于synchronized实现的阻塞互斥,除了需要阻塞操作线程,而且唤醒或者阻塞操作系统级别的原生线程需要从用户态转换到内核态中,这个状态的转换消耗的时间可能比用户代码执行的时间还要长,因此,我们常说 synchronized 是 Java 语言中的“重量级锁”。
非阻塞同步
乐观锁与悲观锁
使用 synchronized 关键字的同步方式最主要的问题就是进行线程阻塞和唤醒时所带来的性能消耗问题。阻塞同步属于悲观的并发策略,只要可能出现竞争,他都认为一定要加锁。
然而同步策略还有另外一种乐观的策略,乐观并发策略先进行数据的操作,如果没有发现其他线程也操作了数据,那么就认为这个操作是成功的,如果发生了其他线程也操作了数据,那么一般采取不断重试的手段,知道成功为止,这种乐观锁的策略不需要把线程阻塞,属于非阻塞同步的一种手段。
CAS
乐观并发策略主要有两个重要的阶段:一个是对数据进行操作;另一个是进行冲突的检测,即检测其他线程有无同时也对该数据进行了操作。这里的数据操作和冲突需要具备原子性,否则就容易出现类似 i++ 的问题。
CAS的含义为 compare and swap,目前绝大多数CPU都原生支持CAS原子指令,例如在IA64的指令集中,就有compxchg这样的指令来完成CAS的功能,它的原子性要求是在硬件层面上得到保证的。CAS指令一般需要有三个参数,分别是:内存地址、期望中的旧值和新值。CAS执行指令时如果该内存地址上的值符合期望中的旧值,处理器就会用新值更新该内存地址上的值,否则就不更新,这个操作在CPU内部是保证了原子性的。
Java中有很多与CAS相关的API,常见的有java.util.concurrent.atomic包下的各种原子类,如:AtomicInteger、AtomicReference等。这些类都支持CAS操作,其内部实际上也依赖于sun.misc.Unsafe这个类里的compareAndAwapInt()和compareAndSwapLong()方法。
CAS并非是完美无缺的,尽管它能保证原子性,但它存在一个筑梦的ABA问题:一个变量初次读取的时候值为A,那么我们是否能说明这个变量在两次读取中间没有发生过变化?——不能。
在这期间,变量可能由A变为B,然后再由B变回A,第二次读取的时候看到的是A,但实际上这个变量已经发生了变化。一般的代码逻辑不会在意这个ABA问题,以为根据代码逻辑它不会影响并发的安全性,但如果在意的话,可能考虑采用阻塞同步的方式而不是CAS。实际上JDK本身对这个ABA问题解决方案提供了AtomicStampedReference这个类,为变量加上版本解决ABA问题。
自旋锁
以synchronized为代表的阻塞同步,因为阻塞线程会恢复线程的操作都需要涉及到操作系统层面的用户态和内核态之间的切换,这对系统的性能影响很大。自旋锁的策略是当前线程去获取一个锁时,如果发现该锁已被其他线程占有,那么它不马上放弃CPU的执行时间片,而是进入一个“无意义”的循环,查看该线程是否已经放弃了锁。
但自旋锁适用于临界区比较小的情况,如果锁持有的时间过长,那么自旋操作本身就会消耗系统的性能。
以下是一个简单的自旋锁实现:
Import java.util.concurrent.atomic.AtomicReference;
Public class SpinLock {
Private AtomicReference<Thread> owner = new AtomicReference<>();
Public void lock(){
Thread currentThread = Thread.currentThread();
While(!owner.compareAndSet(null,currentThread)){}
}
Public void unlock(){
Thread currentThread = Thread.currentThread();
Owner.compareAndSet(currentThread,null);
}
}
上述代码中,owner变量保存获得了锁的线程。这里的自旋做有一些缺点:第一个是没有保证公平性,等待获取锁的线程之间,无法按先后顺序分别获得锁;另一个,由于多个线程回去操作同一个变量owner,在CPU的系统中,存在者各个CPU之间的缓存数据需要同步,保证一致性, 这会带来性能问题。
公平的自旋
为了解决公平性的问题,可以让每个锁拥有一个服务号,表示正在服务的线程,而每个线程尝试获取锁之前需要获取一个排队,然后不断轮询当前锁的服务号是否是自己的服务号,如果是,则表示获得了锁否则就继续轮询。下面是一个简单的实现:
Import java.util.concurrent.atomic.AtomicInteger;
Public class TicketLock {
Private AtomicInteger serviceNum = new AtomicInteger();//服务号
private AtomicInteger ticketNum = new AtomicInteger();//排队号
public int lock(){
// 首先原子性的获取一个排队号
Int myTicketNum = ticketNum.getAndIncrement();
// 只要当前服务号不是自己就不断轮询
while(serviceNum.get() != myTicketNum){}
Return myTicketNum;
}
public void unlock(int myTicket){
Int next = myTicked + 1;
// 只有当前线程拥有者才能释放锁
serviceNum.compareAndSet(myTicketNum,next);
}
}
虽然解决了公平性的问题,但依然存在前面说的多CPU缓存同步问题,因为每个线程占用的CPU都在同时读写同一个变量serviceNum,这回导致繁重的系统总线流量和内存操作次数,从而降低了系统整体的性能。
网友评论