Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
OpenDocCN
modern-java-zh
提交
0552bef9
M
modern-java-zh
项目概览
OpenDocCN
/
modern-java-zh
通知
1
Star
0
Fork
0
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
0
列表
看板
标记
里程碑
合并请求
0
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
M
modern-java-zh
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
0
Issue
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
提交
Issue看板
前往新版Gitcode,体验更适合开发者的 AI 搜索 >>
提交
0552bef9
编写于
7月 23, 2016
作者:
W
wizardforcel
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
ch5
上级
58d7cd16
变更
2
隐藏空白更改
内联
并排
Showing
2 changed file
with
379 addition
and
10 deletion
+379
-10
ch4.md
ch4.md
+14
-10
ch5.md
ch5.md
+365
-0
未找到文件。
ch4.md
浏览文件 @
0552bef9
...
...
@@ -8,12 +8,13 @@
欢迎阅读我的Java8并发教程的第一部分。这份指南将会以简单易懂的代码示例来教给你如何在Java8中进行并发编程。这是一系列教程中的第一部分。在接下来的15分钟,你将会学会如何通过线程,任务(tasks)和 exector services来并行执行代码。
-
第一部分:Threads和Executors
-
第二部分:同步和锁
+
第一部分:
[
线程和执行器
](
ch4.md
)
+
第二部分:
[
同步和锁
](
ch5.md
)
+
第三部分:
[
原子操作和 ConcurrentMap
](
ch6.md
)
并发在Java5中首次被引入并在后续的版本中不断得到增强。在这篇文章中介绍的大部分概念同样适用于以前的Java版本。不过我的代码示例聚焦于Java8,大量使用lambda表达式和其他新特性。如果你对lambda表达式不属性,我推荐你首先阅读我的
[
Java 8 教程
](
http://winterbe.com/posts/2014/03/16/java-8-tutorial/
)
。
##
Threads 和 Runnables
##
`Thread` 和 `Runnable`
所有的现代操作系统都通过进程和线程来支持并发。进程是通常彼此独立运行的程序的实例,比如,如果你启动了一个Java程序,操作系统产生一个新的进程,与其他程序一起并行执行。在这些进程的内部,我们使用线程并发执行代码,因此,我们可以最大限度的利用CPU可用的核心(core)。
...
...
@@ -78,7 +79,7 @@ thread.start();
接下来,让我们走进并发API中最重要的一部——executor services。
##
Executors
##
`Executor`
并发API引入了
`ExecutorService`
作为一个在程序中直接使用Thread的高层次的替换方案。Executos支持运行异步任务,通常管理一个线程池,这样一来我们就不需要手动去创建新的线程。在不断地处理任务的过程中,线程池内部线程将会得到复用,因此,在我们可以使用一个executor service来运行和我们想在我们整个程序中执行的一样多的并发任务。
...
...
@@ -122,7 +123,7 @@ finally {
executor通过等待指定的时间让当前执行的任务终止来“温柔的”关闭executor。在等待最长5分钟的时间后,execuote最终会通过中断所有的正在执行的任务关闭。
###
Callables 和 Futures
###
`Callable` 和 `Future`
除了
`Runnable`
,executor还支持另一种类型的任务——
`Callable`
。Callables也是类似于runnables的函数接口,不同之处在于,Callable返回一个值。
...
...
@@ -174,7 +175,7 @@ future.get();
你可能注意到我们这次创建executor的方式与上一个例子稍有不同。我们使用
`newFixedThreadPool(1)`
来创建一个单线程线程池的 execuot service。
这等同于使用
`newSingleThreadExecutor`
不过使用第二种方式我们可以稍后通过简单的传入一个比1大的值来增加线程池的大小。
###
Timeouts
###
超时
任何
`future.get()`
调用都会阻塞,然后等待直到callable中止。在最糟糕的情况下,一个callable持续运行——因此使你的程序将没有响应。我们可以简单的传入一个时长来避免这种情况。
...
...
@@ -203,7 +204,7 @@ Exception in thread "main" java.util.concurrent.TimeoutException
你可能已经猜到俄为什么会排除这个异常。我们指定的最长等待时间为1分钟,而这个callable在返回结果之前实际需要两分钟。
###
invokeAll
###
`invokeAll`
Executors支持通过
`invokeAll()`
一次批量提交多个callable。这个方法结果一个callable的集合,然后返回一个future的列表。
...
...
@@ -230,7 +231,7 @@ executor.invokeAll(callables)
在这个例子中,我们利用Java8中的函数流(stream)来处理
`invokeAll()`
调用返回的所有future。我们首先将每一个future映射到它的返回值,然后将每个值打印到控制台。如果你还不属性stream,可以阅读我的
[
Java8 Stream 教程
](
http://winterbe.com/posts/2014/07/31/java8-stream-tutorial-examples/
)
。
###
invokeAny
###
`invokeAny`
批量提交callable的另一种方式就是
`invokeAny()`
,它的工作方式与
`invokeAll()`
稍有不同。在等待future对象的过程中,这个方法将会阻塞直到第一个callable中止然后返回这一个callable的结果。
...
...
@@ -265,7 +266,7 @@ System.out.println(result);
ForkJoinPools 在Java7时引入,将会在这个系列后面的教程中详细讲解。让我们深入了解一下 scheduled executors 来结束本次教程。
##
Scheduled Executors
##
`ScheduledExecutor`
我们已经学习了如何在一个 executor 中提交和运行一次任务。为了持续的多次执行常见的任务,我们可以利用调度线程池。
...
...
@@ -325,6 +326,9 @@ executor.scheduleWithFixedDelay(task, 0, 1, TimeUnit.SECONDS);
这是并发系列教程的第一部分。我推荐你亲手实践一下上面的代码示例。你可以从
[
Github
](
https://github.com/winterbe/java8-tutorial
)
上找到这篇文章中所有的代码示例,所以欢迎你fork这个仓库,并
[
收藏它
](
https://github.com/winterbe/java8-tutorial/stargazers
)
。
我希望你会喜欢这篇文章。如果你有任何的问题都可以在下面评论或者通过
[
Twitter
](
https://twitter.com/winterbe_
)
给我回复
。
我希望你会喜欢这篇文章。如果你有任何的问题都可以在下面评论或者通过
[
Twitter
](
https://twitter.com/winterbe_
)
向我反馈
。
+
第一部分:
[
线程和执行器
](
ch4.md
)
+
第二部分:
[
同步和锁
](
ch5.md
)
+
第三部分:
[
原子操作和 ConcurrentMap
](
ch6.md
)
ch5.md
0 → 100644
浏览文件 @
0552bef9
# Java 8 并发教程:同步和锁
> 原文:[Java 8 Concurrency Tutorial: Synchronization and Locks](http://winterbe.com/posts/2015/04/30/java8-concurrency-tutorial-synchronized-locks-examples/)
> 译者:[飞龙](https://github.com/wizardforcel)
> 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
欢迎阅读我的Java8并发教程的第二部分。这份指南将会以简单易懂的代码示例来教给你如何在Java8中进行并发编程。这是一系列教程中的第二部分。在接下来的15分钟,你将会学会如何通过同步关键字,锁和信号量来同步访问共享可变变量。
+
第一部分:
[
线程和执行器
](
ch4.md
)
+
第二部分:
[
同步和锁
](
ch5.md
)
+
第三部分:
[
原子操作和 ConcurrentMap
](
ch6.md
)
这篇文章中展示的中心概念也适用于Java的旧版本,然而代码示例适用于Java 8,并严重依赖于lambda表达式和新的并发特性。如果你还不熟悉lambda,我推荐你先阅读我的
[
Java 8 教程
](
ch1.md
)
。
出于简单的因素,这个教程的代码示例使用了定义在
[
这里
](
https://github.com/winterbe/java8-tutorial/blob/master/src/com/winterbe/java8/samples/concurrent/ConcurrentUtils.java
)
的两个辅助函数
`sleep(seconds)`
和
`stop(executor)`
。
## 同步
在
[
上一章
](
ch4.md
)
中,我们学到了如何通过执行器服务同时执行代码。当我们编写这种多线程代码时,我们需要特别注意共享可变变量的并发访问。假设我们打算增加某个可被多个线程同时访问的整数。
我们定义了
`count`
字段,带有
`increment()`
方法来使
`count`
加一:
```
java
int
count
=
0
;
void
increment
()
{
count
=
count
+
1
;
}
```
当多个线程并发调用这个方法时,我们就会遇到大麻烦:
```
java
ExecutorService
executor
=
Executors
.
newFixedThreadPool
(
2
);
IntStream
.
range
(
0
,
10000
)
.
forEach
(
i
->
executor
.
submit
(
this
::
increment
));
stop
(
executor
);
System
.
out
.
println
(
count
);
// 9965
```
我们没有看到
`count`
为10000的结果,上面代码的实际结果在每次执行时都不同。原因是我们在不同的线程上共享可变变量,并且变量访问没有同步机制,这会产生
[
竞争条件
](
http://en.wikipedia.org/wiki/Race_condition
)
。
增加一个数值需要三个步骤:(1)读取当前值,(2)使这个值加一,(3)将新的值写到变量。如果两个线程同时执行,就有可能出现两个线程同时执行步骤1,于是会读到相同的当前值。这会导致无效的写入,所以实际的结果会偏小。上面的例子中,对
`count`
的非同步并发访问丢失了35次增加操作,但是你在自己执行代码时会看到不同的结果。
幸运的是,Java自从很久之前就通过
`synchronized`
关键字支持线程同步。我们可以使用
`synchronized`
来修复上面在增加
`count`
时的竞争条件。
```
java
synchronized
void
incrementSync
()
{
count
=
count
+
1
;
}
```
在我们并发调用
`incrementSync()`
时,我们得到了
`count`
为10000的预期结果。没有再出现任何竞争条件,并且结果在每次代码执行中都很稳定:
```
java
ExecutorService
executor
=
Executors
.
newFixedThreadPool
(
2
);
IntStream
.
range
(
0
,
10000
)
.
forEach
(
i
->
executor
.
submit
(
this
::
incrementSync
));
stop
(
executor
);
System
.
out
.
println
(
count
);
// 10000
```
`synchronized`
关键字也可用于语句块:
```
java
void
incrementSync
()
{
synchronized
(
this
)
{
count
=
count
+
1
;
}
}
```
Java在内部使用所谓的“监视器”(monitor),也称为监视器锁(monitor lock)或内在锁( intrinsic lock)来管理同步。监视器绑定在对象上,例如,当使用同步方法时,每个方法都共享相应对象的相同监视器。
所有隐式的监视器都实现了重入(reentrant)特性。重入的意思是锁绑定在当前线程上。线程可以安全地多次获取相同的锁,而不会产生死锁(例如,同步方法调用相同对象的另一个同步方法)。
## 锁
并发API支持多种显式的锁,它们由
`Lock`
接口规定,用于代替
`synchronized`
的隐式锁。锁对细粒度的控制支持多种方法,因此它们比隐式的监视器具有更大的开销。
锁的多个实现在标准JDK中提供,它们会在下面的章节中展示。
### `ReentrantLock`
`ReentrantLock`
类是互斥锁,与通过
`synchronized`
访问的隐式监视器具有相同行为,但是具有扩展功能。就像它的名称一样,这个锁实现了重入特性,就像隐式监视器一样。
让我们看看使用
`ReentrantLock`
之后的上面的例子。
```
java
ReentrantLock
lock
=
new
ReentrantLock
();
int
count
=
0
;
void
increment
()
{
lock
.
lock
();
try
{
count
++;
}
finally
{
lock
.
unlock
();
}
}
```
锁可以通过
`lock()`
来获取,通过
`unlock()`
来释放。把你的代码包装在
`try-finally`
代码块中来确保异常情况下的解锁非常重要。这个方法是线程安全的,就像同步副本那样。如果另一个线程已经拿到锁了,再次调用
`lock()`
会阻塞当前线程,直到锁被释放。在任意给定的时间内,只有一个线程可以拿到锁。
锁对细粒度的控制支持多种方法,就像下面的例子那样:
```
java
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
);
```
在第一个任务拿到锁的一秒之后,第二个任务获得了锁的当前状态的不同信息。
```
Locked: true
Held by me: false
Lock acquired: false
```
`tryLock()`
方法是
`lock()`
方法的替代,它尝试拿锁而不阻塞当前线程。在访问任何共享可变变量之前,必须使用布尔值结果来检查锁是否已经被获取。
### `ReadWriteLock`
`ReadWriteLock`
接口规定了锁的另一种类型,包含用于读写访问的一对锁。读写锁的理念是,只要没有任何线程写入变量,并发读取可变变量通常是安全的。所以读锁可以同时被多个线程持有,只要没有线程持有写锁。这样可以提升性能和吞吐量,因为读取比写入更加频繁。
```
java
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
();
}
});
```
上面的例子在暂停一秒之后,首先获取写锁来向映射添加新的值。在这个任务完成之前,两个其它的任务被启动,尝试读取映射中的元素,并暂停一秒:
```
java
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`
的锁方法会返回表示为
`long`
的标记。你可以使用这些标记来释放锁,或者检查锁是否有效。此外,
`StampedLock`
支持另一种叫做乐观锁(optimistic locking)的模式。
让我们使用
`StampedLock`
代替
`ReadWriteLock`
重写上面的例子:
```
java
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
);
```
通过
`readLock()`
或
`writeLock()`
来获取读锁或写锁会返回一个标记,它可以在稍后用于在
`finally`
块中解锁。要记住
`StampedLock`
并没有实现重入特性。每次调用加锁都会返回一个新的标记,并且在没有可用的锁时阻塞,即使相同线程已经拿锁了。所以你需要额外注意不要出现死锁。
就像前面的
`ReadWriteLock`
例子那样,两个读任务都需要等待写锁释放。之后两个读任务同时向控制台打印信息,因为多个读操作不会相互阻塞,只要没有线程拿到写锁。
下面的例子展示了乐观锁:
```
java
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()`
获取,它总是返回一个标记而不阻塞当前线程,无论锁是否真正可用。如果已经有写锁被拿到,返回的标记等于0。你需要总是通过
`lock.validate(stamp)`
检查标记是否有效。
执行上面的代码会产生以下输出:
```
Optimistic Lock Valid: true
Write Lock acquired
Optimistic Lock Valid: false
Write done
Optimistic Lock Valid: false
```
乐观锁在刚刚拿到锁之后是有效的。和普通的读锁不同的是,乐观锁不阻止其他线程同时获取写锁。在第一个线程暂停一秒之后,第二个线程拿到写锁而无需等待乐观的读锁被释放。此时,乐观的读锁就不再有效了。甚至当写锁释放时,乐观的读锁还处于无效状态。
所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。
有时,将读锁转换为写锁而不用再次解锁和加锁十分实用。
`StampedLock`
为这种目的提供了
`tryConvertToWriteLock()`
方法,就像下面那样:
```
java
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
);
```
第一个任务获取读锁,并向控制台打印
`count`
字段的当前值。但是如果当前值是零,我们希望将其赋值为
`23`
。我们首先需要将读锁转换为写锁,来避免打破其它线程潜在的并发访问。
`tryConvertToWriteLock()`
的调用不会阻塞,但是可能会返回为零的标记,表示当前没有可用的写锁。这种情况下,我们调用
`writeLock()`
来阻塞当前线程,直到有可用的写锁。
## 信号量
除了锁之外,并发API也支持计数的信号量。不过锁通常用于变量或资源的互斥访问,信号量可以维护整体的准入许可。这在一些不同场景下,例如你需要限制你程序某个部分的并发访问总数时非常实用。
下面是一个例子,演示了如何限制对通过
`sleep(5)`
模拟的长时间运行任务的访问:
```
java
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
);
```
执行器可能同时运行10个任务,但是我们使用了大小为5的信号量,所以将并发访问限制为5。使用
`try-finally`
代码块在异常情况中合理释放信号量十分重要。
执行上述代码产生如下结果:
```
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
```
信号量限制对通过
`sleep(5)`
模拟的长时间运行任务的访问,最大5个线程。每个随后的
`tryAcquire()`
调用在经过最大为一秒的等待超时之后,会向控制台打印不能获取信号量的结果。
这就是我的系列并发教程的第二部分。以后会放出更多的部分,所以敬请等待吧。像以前一样,你可以在
[
Github
](
https://github.com/winterbe/java8-tutorial
)
上找到这篇文档的所有示例代码,所以请随意fork这个仓库,并自己尝试它。
我希望你能喜欢这篇文章。如果你还有任何问题,在下面的评论中向我反馈。你也可以
[
在Twitter上关注我
](
https://twitter.com/winterbe_
)
来获取更多开发相关的信息。
+
第一部分:
[
线程和执行器
](
ch4.md
)
+
第二部分:
[
同步和锁
](
ch5.md
)
+
第三部分:
[
原子操作和 ConcurrentMap
](
ch6.md
)
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录