Synchronization and Locks

Synchronized

上一章节中,我们初步了解了如何通过ExecutorService来执行并发代码,注意,我们并没有深入讨论多线程间的同步问题,本章,我们将重点讨论线程间同步问题,深入剖析多线程间如何优雅的共享变量,首先,我们从一个简单的例子入手。

int count = 0;

void increment() {
    count = count + 1;
}

如果没有任何同步措施,多线程环境下,这段代码的执行结果可能与预期(10000)大大不同,甚至每次执行都可能得到不通的结果。导致这一现象的主要原因是我们在多个线程间共享了同一个变量,但却没有执行同步措施,从而导致了 竞争条件

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::increment));

stop(executor);

System.out.println(count);  // 9965

让我们重新回到上面的例子中,如果要对一个变量执行增加操作,我们需要以下三步操作:

  • 读取当前值
  • 执行增加操作
  • 将最新值重新赋予当前变量

如果两个线程并发同时执行第一步操作,那么最终的执行结果自然要比实际结果小,因为其中一个线程的增加操作被覆盖从而导致执行结果丢失。幸运的是,Java从一开始就提供了线程同步机制,我们可以通过synchronized关键字来解决上述由竞争条件所导致的问题。

synchronized void incrementSync() {
    count = count + 1;
}

使用了synchronized关键字的incrementSync()方法在多线程下执行结果与预期一致

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::incrementSync));

stop(executor);

System.out.println(count);  // 10000

synchronized关键字也允许运用在块模块中。

void incrementSync() {
    synchronized (this) {
        count = count + 1;
    }
}

Java使用监视器(又名:监视器锁或内部锁)来管理同步,锁本身与对象绑定,当使用synchronized来修饰方法时,方法内部共享目标的锁对象。所有的内部锁都包含实现了可重入的特征,可重入意味着锁与当前线程绑定,一个线程可以非常安全的多次获取同一锁而不会导致死锁发生。

Locks

除使用synchronized关键字实现同步外,Concurrency API并发框架还提供了各种显式锁对象来实现同步,显式锁提供了更为细粒度的锁机制管理。

ReentrantLock

可重入锁:与synchronized内部锁一致,ReentrantLock提供的也是一种排他锁机制,但功能更加丰富。正如名字所示,ReentrantLock提供的是可重入特性。我们看下,上面的加法操作如果通过ReentrantLock来实现。

ReentrantLock lock = new ReentrantLock();
int count = 0;

void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

注意,ReentrantLock的最佳实践,一定要通过try/finally来处理异常以确保锁对象可以被正确释放。下面的例子为我们展示了ReentrantLock是如何支持更细粒度的同步控制逻辑。

ExecutorService executor = Executors.newFixedThreadPool(2);
ReentrantLock lock = new ReentrantLock();

executor.submit(() -> {
    lock.lock();
    try {
        sleep(1);
    } finally {
        lock.unlock();
    }
});

executor.submit(() -> {
    System.out.println("Locked: " + lock.isLocked());
    System.out.println("Held by me: " + lock.isHeldByCurrentThread());
    boolean locked = lock.tryLock();
    System.out.println("Lock acquired: " + locked);
});

stop(executor);

执行结果如下:

扫描二维码关注公众号,回复: 3078708 查看本文章
Locked: true
Held by me: false
Lock acquired: false

注意:相比于lock方法,tryLock()方法并不中止当前线程,这也是ReentrantLock为我们提供的更为优雅的获取对象锁的一种方式。

ReadWriteLock

读写锁:为我们提供了一对用于读写访问的锁。读写锁背后的思想是,只要没有人对这个变量执行写操作,那么多个线程就可以同时安全的读取共享变量。因此,只要没有线程持有写操作锁,那么就可以由多个线程同时持有读锁。这对于读多写少的业务场景,可以显著的提高性能和吞吐量。

获取写锁以更新对象

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();

executor.submit(() -> {
    lock.writeLock().lock();
    try {
        sleep(1);
        map.put("foo", "bar");
    } finally {
        lock.writeLock().unlock();
    }
});

获取读锁读取数据

Runnable readTask = () -> {
    lock.readLock().lock();
    try {
        System.out.println(map.get("foo"));
        sleep(1);
    } finally {
        lock.readLock().unlock();
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);

在写锁释放之后,两个读任务并行执行,并同时将结果打印到控制台。它们不必等待对方完成,因为只要其他线程没有持有写锁,就可以安全地同时获得读锁。

StampedLock

Java 8引入了一种新的锁StampedLock,与ReadWriteLock类似,StampedLock同样支持读写锁。与ReadWriteLock不同的是,StampedLocklock方法返回了一个long长整型的stamp戳值,你可以使用这个stamp值执行释放锁操作或者校验锁对象是否有效。除此之外,StampedLock还支持另一种锁模式,我们通常称之为“乐观锁”。

让我们重写上面的例子

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        sleep(1);
        map.put("foo", "bar");
    } finally {
        lock.unlockWrite(stamp);
    }
});

Runnable readTask = () -> {
    long stamp = lock.readLock();
    try {
        System.out.println(map.get("foo"));
        sleep(1);
    } finally {
        lock.unlockRead(stamp);
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);

注意:StampedLock并不具备可重入特性,这也就意味着,每次调用lock方法,当前线程都会得到一个新的stamp对象值,如果没有可用锁对象,那么将阻塞当前线程,即便是当前线程已经持有了目标的锁对象,所以,使用StampedLock要十分小心进入死锁。

下面的例子展示了“乐观锁”

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.tryOptimisticRead();
    try {
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(1);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(2);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
    } finally {
        lock.unlock(stamp);
    }
});

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        System.out.println("Write Lock acquired");
        sleep(2);
    } finally {
        lock.unlock(stamp);
        System.out.println("Write done");
    }
});

stop(executor);

乐观读锁的概念是指,无论是否锁是否可用,tryOptimisticRead()都会以非阻塞的方式返回一个stamp对象值,如果已经有其他线程获取到了读锁,那么返回的stamp对象就等于0,要确认当前锁是否可用,可以调用
lock.validate(stamp)方法。

Optimistic Lock Valid: true
Write Lock acquired
Optimistic Lock Valid: false
Write done
Optimistic Lock Valid: false

乐观锁在获得锁后立即有效。与普通的读锁不同,乐观锁不会阻止其他线程立即获得写锁。在将第一个线程休眠一秒钟之后,第二个线程可立即获得一个写锁,而无需等待乐观读锁被释放。只是当写锁被第二个线程获取之后,第一个线程的乐观读锁将不再有效。即使第二个线程释放了写锁,第个线程的乐观读锁仍然是无效。

所以,对于乐观锁,你唯一需要注意的是,每次使用前你都要确认当前已获取的锁对象是否仍然有效。

在有些业务场景中,我们需要将已获取的读锁转换为写锁,正常情况,我们需要释放当前读锁,然后重新获取写锁对象。StampedLock提供了tryConvertToWriteLock()方法可以将当前读锁转换为写锁而无需释放当前读锁。下面的例子示例说明了这一用法。

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.readLock();
    try {
        if (count == 0) {
            stamp = lock.tryConvertToWriteLock(stamp);
            if (stamp == 0L) {
                System.out.println("Could not convert to write lock");
                stamp = lock.writeLock();
            }
            count = 23;
        }
        System.out.println(count);
    } finally {
        lock.unlock(stamp);
    }
});

stop(executor);

Semaphores

除了各种用以同步的锁对象,Concurrency API还支持信号量的概念。锁通常授予对变量或资源的独占访问权,但是信号量提供维护的访问权限的许可集合,这种机制在必须限制并发访问量的业务场景中非常有用。

ExecutorService executor = Executors.newFixedThreadPool(10);

Semaphore semaphore = new Semaphore(5);

Runnable longRunningTask = () -> {
    boolean permit = false;
    try {
        permit = semaphore.tryAcquire(1, TimeUnit.SECONDS);
        if (permit) {
            System.out.println("Semaphore acquired");
            sleep(5);
        } else {
            System.out.println("Could not acquire semaphore");
        }
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    } finally {
        if (permit) {
            semaphore.release();
        }
    }
}

IntStream.range(0, 10)
    .forEach(i -> executor.submit(longRunningTask));

stop(executor);

执行结果如下:

Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore

https://winterbe.com/posts/2015/04/30/java8-concurrency-tutorial-synchronized-locks-examples/

猜你喜欢

转载自blog.csdn.net/kangkanglou/article/details/82350768