共计 6600 个字符,预计需要花费 17 分钟才能阅读完成。
1. 多线程基本概念
1.1 轻量级过程
在 JVM 中,一个线程,其实是一个轻量级过程(LWP)。所谓的轻量级过程,其实是用户过程调用零碎内核,所提供的一套接口。实际上,java 培训它还要调用更加底层的内核线程(KLT)。
实际上,JVM 的线程创立销毁以及调度等,都是依赖于操作系统的。如果你看一下 Thread 类外面的多个函数,你会发现很多都是 native 的,间接调用了底层操作系统的函数。
下图是 JVM 在 Linux 上简略的线程模型。
能够看到,不同的线程在进行切换的时候,会频繁在用户态和内核态进行状态转换。这种切换的代价是比拟大的,也就是咱们平时所说的上下文切换(Context Switch)。
1.2 JMM
在介绍线程同步之前,咱们有必要介绍一个新的名词,那就是 JVM 的内存模型 JMM。
JMM 并不是说堆、metaspace 这种内存的划分,它是一个齐全不同的概念,指的是与线程相干的 Java 运行时线程内存模型。
因为 Java 代码在执行的时候,很多指令都不是原子的,如果这些值的执行程序产生了错位,就会取得不同的后果。比方,i++ 的动作就能够翻译成以下的字节码。
getfield // Field value:I
iconst_1
iadd
putfield // Field value:I
这还只是代码层面的。如果再加上 CPU 每核的各级缓存,这个执行过程会变得更加细腻。如果咱们心愿执行完 i ++ 之后,再执行 i –,仅靠高级的字节码指令,是无奈实现的。咱们须要一些同步伎俩。
上图就是 JMM 的内存模型,它分为主存储器(Main Memory)和工作存储器(Working Memory)两种。咱们平时在 Thread 中操作这些变量,其实是操作的主存储器的一个正本。当批改完之后,还须要从新刷到主存储器上,其余的线程才可能晓得这些变动。
1.3 Java 中常见的线程同步形式
为了实现 JMM 的操作,实现线程之间的变量同步,Java 提供了十分多的同步伎俩。
- Java 的基类 Object 中,提供了 wait 和 notify 的原语,来实现 monitor 之间的同步。不过这种操作咱们在业务编程中很少遇见
- 应用 synchronized 对办法进行同步,或者锁住某个对象以实现代码块的同步
- 应用 concurrent 包外面的可重入锁。这套锁是建设在 AQS 之上的
- 应用 volatile 轻量级同步关键字,实现变量的实时可见性
- 应用 Atomic 系列,实现自增自减
- 应用 ThreadLocal 线程局部变量,实现线程关闭
- 应用 concurrent 包提供的各种工具,比方 LinkedBlockingQueue 来实现生产者消费者。实质还是 AQS
- 应用 Thread 的 join,以及各种 await 办法,实现并发工作的程序执行
从下面的形容能够看出,多线程编程要学的货色可切实太多了。侥幸的是,同步形式尽管变幻无穷,但咱们创立线程的形式却没几种。
第一类就是 Thread 类。大家都晓得有两种实现形式。第一能够继承 Thread 笼罩它的 run 办法;第二种是实现 Runnable 接口,实现它的 run 办法;而第三种创立线程的办法,就是通过线程池。
其实,到最初,就只有一种启动形式,那就是 Thread。线程池和 Runnable,不过是一种封装好的快捷方式罢了。
多线程这么简单,这么容易出问题,那常见的都有那些问题,咱们又该如何防止呢?上面,我将介绍 10 个高频呈现的坑,并给出解决方案。
2. 避坑指南
2.1. 线程池打爆机器
首先,咱们聊一个十分十分低级,但又产生了严重后果的多线程谬误。
通常,咱们创立线程的形式有 Thread,Runnable 和线程池三种。随着 Java1.8 的遍及,当初最罕用的就是线程池形式。
有一次,咱们线上的服务器呈现了僵死,就连近程 ssh,都登录不上,只能无奈的重启。大家发现,只有启动某个利用,过不了几分钟,就会呈现这种状况。最终定位到了几行让人哭笑不得的代码。
有位对多线程不太熟悉的同学,应用了线程池去异步解决音讯。通常,咱们都会把线程池作为类的动态变量,或者是成员变量。然而这位同学,却将它放在了办法外部。也就是说,每当有一个申请到来的时候,都会创立一个新的线程池。当申请量一减少,系统资源就被耗尽,最终造成整个机器的僵死。
void realJob(){
ThreadPoolExecutor exe = new ThreadPoolExecutor(...);
exe.submit(new Runnable(){...})
}
这种问题如何去防止?只能通过代码 review。所以多线程相干的代码,哪怕是非常简单的同步关键字,都要交给有教训的人去写。即便没有这种条件,也要十分认真的对这些代码进行 review。
2.2. 锁要敞开
相比拟 synchronized 关键字加的独占锁,concurrent 包外面的 Lock 提供了更多的灵活性。能够依据须要,抉择偏心锁与非偏心锁、读锁与写锁。
但 Lock 用完之后是要敞开的,也就是 lock 和 unlock 要成对呈现,否则就容易呈现锁泄露,造成了其余的线程永远了拿不到这个锁。
如上面的代码,咱们在调用 lock 之后,产生了异样,try 中的执行逻辑将被中断,unlock 将永远没有机会执行。在这种状况下,线程获取的锁资源,将永远无奈开释。
private final Lock lock = new ReentrantLock();
void doJob(){
try{lock.lock();
// 产生了异样
lock.unlock();}catch(Exception e){}
}
正确的做法,就是将 unlock 函数,放到 finally 块中,确保它总是可能执行。
因为 lock 也是一个一般的对象,是能够作为函数的参数的。如果你把 lock 在函数之间传来传去的,同样会有时序逻辑凌乱的状况。在平时的编码中,也要防止这种把 lock 当参数的状况。
2.3. wait 要包两层
Object 作为 Java 的基类,提供了四个办法 wait wait(timeout) notify notifyAll,用来解决线程同步问题,能够看出 wait 等函数的位置是如许的高大。在平时的工作中,写业务代码的同学应用这些函数的机率是比拟小的,所以一旦用到很容易出问题。
但应用这些函数有一个十分大的前提,那就是必须应用 synchronized 进行包裹,否则会抛出 IllegalMonitorStateException。比方上面的代码,在执行的时候就会报错。
final Object condition = new Object();
public void func(){
condition.wait();
}
相似的办法,还有 concurrent 包里的 Condition 对象,应用的时候也必须呈现在 lock 和 unlock 函数之间。
为什么在 wait 之前,须要先同步这个对象呢?因为 JVM 要求,在执行 wait 之时,线程须要持有这个对象的 monitor,显然同步关键字可能实现这个性能。
然而,仅仅这么做,还是不够的,wait 函数通常要放在 while 循环里才行,JDK 在代码里做了明确的正文。
重点:这是因为,wait 的意思,是在 notify 的时候,可能向下执行逻辑。但在 notify 的时候,这个 wait 的条件可能曾经是不成立的了,因为在期待的这段时间里条件条件可能产生了变动,须要再进行一次判断,所以写在 while 循环里是一种简略的写法。
final Object condition = new Object();
public void func(){
synchronized(condition){
while(< 条件成立 >){
condition.wait();
}
}
}
带 if 条件的 wait 和 notify 要包两层,一层 synchronized,一层 while,这就是 wait 等函数的正确用法。
2.4. 不要笼罩锁对象
应用 synchronized 关键字时,如果是加在一般办法上的,那么锁的就是 this 对象;如果是加载 static 办法上的,那锁的就是 class。除了用在办法上,synchronized 还能够间接指定要锁定的对象,锁代码块,达到细粒度的锁管制。
如果这个锁的对象,被笼罩了会怎么样?比方上面这个。
List listeners = new ArrayList();
void add(Listener listener, boolean upsert){
synchronized(listeners){List results = new ArrayList();
for(Listener ler:listeners){...}
listeners = results;
}
}
下面的代码,因为在逻辑中,强行给锁 listeners 对象进行了从新赋值,会造成锁的错乱或者生效。
为了保险起见,咱们通常把锁对象申明成 final 类型的。
final List listeners = new ArrayList();
或者间接申明专用的锁对象,定义成一般的 Object 对象即可。
final Object listenersLock = new Object();
2.5. 解决循环中的异样
在异步线程里解决一些定时工作,或者执行工夫十分长的批量解决,是常常遇到的需要。我就不止一次看到小伙伴们的程序执行了一部分就进行的状况。
排查到这些停止的根本原因,就是其中的某行数据产生了问题,造成了整个线程的死亡。
咱们还是来看一下代码的模板。
volatile boolean run = true;
void loop(){
while(run){for(Task task: taskList){
//do . sth
int a = 1/0;
}
}
}
在 loop 函数中,执行咱们真正的业务逻辑。当执行到某个 task 的时候,产生了异样。这个时候,线程并不会持续运行上来,而是会抛出异样间接停止。在写一般函数的时候,咱们都晓得程序的这种行为,但一旦到了多线程,很多同学都会忘了这一环。
值得注意的是,即便是非捕捉类型的 NullPointerException,也会引起线程的停止。所以,时刻把要执行的逻辑,放在 try catch 中,是个十分好的习惯。
volatile boolean run = true;
void loop(){
while(run){for(Task task: taskList){
try{
//do . sth
int a = 1/0;
}catch(Exception ex){//log}
}
}
}
2.6. HashMap 正确用法
HashMap 在多线程环境下,会产生死循环问题。这个问题曾经失去了宽泛的遍及,因为它会产生十分重大的结果:CPU 跑满,代码无奈执行,jstack 查看时阻塞在 get 办法上。
至于怎么进步 HashMap 效率,什么时候转红黑树转列表,这是下里巴人的八股界话题,咱们下里巴人只关注怎么不出问题。
网络上有具体的文章形容死循环问题产生的场景,大体因为 HashMap 在进行 rehash 时,会造成环形链。某些 get 申请会走到这个环上。JDK 并不认为这是个 bug,尽管它的影响比拟顽劣。
如果你判断你的汇合类会被多线程应用,那就能够应用线程平安的 ConcurrentHashMap 来代替它。
HashMap 还有一个平安删除的问题,和多线程关系不大,但它抛出的是 ConcurrentModificationException,看起来像是多线程的问题。咱们一块来看看它。
Map<String, String> map = new HashMap<>();
map.put(“xjjdog0”, “ 狗 1 ”);
map.put(“xjjdog1”, “ 狗 2 ”);
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if ("xjjdog0".equals(key)) {map.remove(key);
}
}
下面的代码会抛出异样,这是因为 HashMap 的 Fail-Fast 机制。如果咱们想要平安的删除某些元素,应该应用迭代器。
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, String> entry = iterator.next();
String key = entry.getKey();
if (“xjjdog0”.equals(key)) {
iterator.remove();
}
}
2.7. 线程平安的爱护范畴
应用了线程平安的类,写进去的代码就肯定是线程平安的么?答案是否定的。
线程平安的类,只负责它外部的办法是线程平安的。如我咱们在里面把它包了一层,那么它是否能达到线程平安的成果,就须要从新探讨。
比方上面这种状况,咱们应用了线程平安的 ConcurrentHashMap 来存储计数。尽管 ConcurrentHashMap 自身是线程平安的,不会再呈现死循环的问题。但 addCounter 函数,显著是不正确的,它须要应用 synchronized 函数包裹才行。
private final ConcurrentHashMap<String,Integer> counter;
public int addCounter(String name) {
Integer current = counter.get(name);
int newValue = ++current;
counter.put(name,newValue);
return newValue;
}
这是开发人员常踩的坑之一。要达到线程平安,须要看一下线程平安的作用范畴。如果更大维度的逻辑存在同步问题,那么即便应用了线程平安的汇合,也达不到想要的成果。
2.8. volatile 作用无限
volatile 关键字,解决了变量的可见性问题,能够让你的批改,立马让其余线程给读到。
尽管这个货色在面试的时候问的挺多的,包含 ConcurrentHashMap 中队 volatile 的那些优化。但在平时的应用中,你真的可能只会接触到 boolean 变量的值批改。
volatile boolean closed;
public void shutdown() {
closed = true;
}
千万不要把它用在计数或者线程同步上,比方上面这样。
volatile count = 0;
void add(){
++count;
}
这段代码在多线程环境下,是不精确的。这是因为 volatile 只保障可见性,不保障原子性,多线程操作并不能保障其正确性。
间接用 Atomic 类或者同步关键字多好,你真的在乎这纳秒级别的差别么?
2.9. 日期解决要小心
很多时候,日期解决也会出问题。这是因为应用了全局的 Calendar,SimpleDateFormat 等。当多个线程同时执行 format 函数的时候,就会呈现数据错乱。
SimpleDateFormat format = new SimpleDateFormat(“yyyy-MM-dd hh:mm:ss”);
Date getDate(String str){
return format(str);
}
为了改良,咱们通常将 SimpleDateFormat 放在 ThreadLocal 中,每个线程一份拷贝,这样能够防止一些问题。当然,当初咱们能够应用线程平安的 DateTimeFormatter 了。
static DateTimeFormatter FOMATTER = DateTimeFormatter.ofPattern(“MM/dd/yyyy HH:mm:ss”);
public static void main(String[] args) {
ZonedDateTime zdt = ZonedDateTime.now();
System.out.println(FOMATTER.format(zdt));
}
2.10. 不要在构造函数中启动线程
在构造函数,或者 static 代码块中启动新的线程,并没有什么谬误。然而,强烈不举荐你这么做。
因为 Java 是有继承的,如果你在构造函数中做了这种事,那么子类的行为将变得十分魔幻。另外,this 对象可能在结构结束之前,出递到另外一个中央被应用,造成一些不可意料的行为。
所以把线程的启动,放在一个一般办法,比方 start 中,是更好的抉择。它能够缩小 bug 产生的机率。
起源:小姐姐滋味 作者:小姐姐养的狗