Java并发系列 1

Wesley13
• 阅读 566

程序开发中并发的场景还是比较常见的,特别是当下分布式环环境开发大行其道的情况下,从前端处理,到服务调用、缓存处理、数据库处理、文件处理、消息处理等等,无不需要并发的知识。
从今天开始,我要写一个关于Java并发的系列文章,希望各位可以从中受益。
我们先从基础的线程开始说起!

一、线程基础知识

从宏观来看,简单说一下进程:所谓进程就是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
线程是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。一个进程中一般会有多个线程。
正式学习之前,最好先了解一下线程的生命周期,做到观其大略:

  1. 新建(NEW):新创建了一个线程对象。
  2. 可运行(RUNNABLE):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。
  3. 运行(RUNNING):可运行状态(runnable)的线程获得了cpu 时间片(timeslice),执行程序代码。
  4. 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。阻塞的情况分三种:
    (一). 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
    (二). 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
    (三). 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。
  5. 死亡(DEAD):线程run()、main() 方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

读完之后,可能还是会有一部分不太理解,问题不大,留个印象即可。

二、线程基本操作

1、新建并启动

Java中实现线程有两种方式,一种是继承Thread,一种是实现Runnable。直接看代码:

public class HelloThread extends Thread{
    @Override
    public void run() {
        System.out.println("hello");
    }
}
//和
public class HelloRunnable implements Runnable{
    @Override
    public void run() {
        System.out.println("hello runnable");
    }
}

有了线程的主体,来看看如何新建并启动线程:

new HelloThread().start();
//和
new Thread(new HelloRunnable()).start();

继承的Thread可以直接新建,并用start()方法启动;至于Runnable接口,则需要传入Thread来启动。

2、终止

原来线程中是有一个stop()方法的,现在已经被废弃,不建议使用。
原因呢,是因为stop()方法会强制终止线程,可能造成数据不一致的情况。
可以考虑通过另外的方式来终止线程:

class HelloThread extends Thread{

    private boolean isStop;
    
    @Override
    public void run() {
        try {
            sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        if (!isStop) {
            System.out.println("hello thread");
        }
    }
    
    public void stopMe() {
        isStop = true;
    }
    
}


//main中的代码
HelloThread thread = new HelloThread();
thread.start();
thread.stopMe();

3、中断

因为stop()方法被弃用了,那么JDK中有没有更好的终止线程的方法呢?
当然有,就是线程中断。
线程中断的工作方式是:给执行中的线程发送一个通知,告诉他有人希望你退出了。执行线程收到通知后怎么做,取决于他自己的逻辑实现。
所谓的中断就是收到了停止的通知,请立刻停下你的魔爪!
Thread中有两个实例方法和一个静态方法,分别是:

  • interrupt() 实例方法,它用来通知目标线程该停止了,请听指挥
  • isInterrupted() 实例方法,判断当前线程是否收到了停止通知
  • interrupted() 静态方法,也是判断当前线程是否收到了停止通知,但同时会清除当前线程的中断标志位状态

先来一个简单的例子,演示一下前两个方法:

class HelloThread extends Thread {
    @Override
    public void run() {
        while (true) {
            if (Thread.currentThread().isInterrupted()) {
                System.out.println("stop!");
                break;
            }
        }
    }
}

//main中执行
HelloThread thread = new HelloThread();
thread.start();
thread.interrupt();

主要逻辑是:有人希望某个线程退出,然后调用了那个线程的interrupt()方法,执行中的目标线程会判断自己是否收到了这个通知,如果收到,则听党指挥、能打胜仗,鸣金收兵了!当然,也可以不对中断进行处理,那样不太规范,最好不要那样做。


因为线程的阻塞方式有三种,所以关于interrupt()方法的逻辑会稍微复杂一点,JDK文档上是这样说的:

如果当前线程被阻塞在wait系列方法、sleep系列方法或join系列方法时,则当前线程的中断状态被清除,并抛出InterruptedException异常;
如果当前线程被阻塞在InterruptibleChannel的I/O上,则channel将被关闭,并为线程设置中断状态,线程还会收到一个ClosedByInterruptException;
如果当前线程被阻塞在nio的Selector 上,则会为线程设置中断状态,并立马返回selection operation;
如果没有出现上述阻塞情况,则只会为线程设置中断状态

根据开头我们说的线程的三种阻塞状态来看,等待阻塞和其他阻塞中断机制可以很好的解决,那么同步阻塞呢?也就是遇见了synchronized或Lock.lock()的时候,中断机制又是如何运行呢?
答案是如果遇到同步阻塞,收到中断通知的线程并不会抛出异常。
这该如何是好?也许只好在某些节点用Thread.currentThread().isInterrupted()方法来判断了。
还好幸运的是,后面我们会讲到的Java并发工具包中的那些工具类,对于中断机制的支持还是比较完善,比如对于Lock类来说,可以改用Lock.lockInterruptibly()来加锁,至于用到synchronized关键字的话则需要注意处理方式了。

4、等待和通知

如果需要当前线程等待某个事件,事件完成获得通知之后再运行,就需要用到wait()和notify()方法了。
比如,线程A中,调用了obj.wait()方法,那么线程A就会停止继续执行,而转为等待状态。等到线程B调用了obj.notify()方法为止。这时,obj对象就俨然成为了多个线程之间的有效通信手段。 直接看代码:

public class Demo {
    final static Object object = new Object();

    public static class T1 extends Thread {

        public void run() {
            synchronized (object) {
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

    }

    public static class T2 extends Thread {
        public void run() {
            synchronized (object) {
                object.notify();
            }
        }
    }
}

调用代码的逻辑可以是:启动T1,然后阻塞在T1;启动T2之后,T1和T2都运行中状态,然后终止。
obj.wait()并不是可以随便调用的,它必须包含在对应的synchronized语句中,无论是wait()或notify()都需要首先获得目标对象的一个监视器。这一点可以在代码中得到体现。
在唤醒线程层面,obj.notify()的时候,会随机选择一个线程来唤醒;另外还有一个notifyAll()方法,会唤起所有等待的线程,而不是随机一个。

5、join和yield

这两个方法分别用来让另一个线程加入和自身线程的谦让。

  • join:当前线程A调用线程B的join()方法,线程A将进入到阻塞状态,直到线程B运行结束,A才会继续运行。通俗说,就是A和B关系好,A让B插队了
  • yield:当前线程A执行了yield()方法,如果具有相同优先级的线程处于就绪状态,那么当前线程会让出CPU给相同优先级的线程运行的机会。如果没有相同优先级的线程,那么什么都不做。通俗说,就是线程A比较谦虚,功劳都让别人先领

三、synchronized关键字

上面只讲到线程的基础,包括线程的建立和一些简单的协作。
如果你要开始编写一个多线程的程序,那么就会面对很多问题。为了便于程序员之间的沟通,需要先了解一下下面的概念:

  • 临界区:如果有一个共享资源(或称公共区域),多个线程都要访问它。但是为保证数据一致,每次都仅能有一个线程在使用这片公共区域,那么这个公共区域就叫做临界区
  • 阻塞与非阻塞:阻塞这个词很多地方都能看到,不同场景下的解释应该略有差异。比如I/O阻塞,指的是系统调用的read或write被阻塞了,需要等待;比如线程阻塞,指的是线程访问临界区时,已有其他线程占用了临界区,本线程被阻塞。

接下来思考一个问题,也可以自己动手实验一下:如果多个线程对一个int数字进行修改,不做同步处理的情况下,这个数字的最终结果会符合我们预期么?

public class Demo {
    static int num = 0;

    public static class T1 extends Thread {

        public void run() {
            num++;
        }

    }

    public static void main(String[] args) throws InterruptedException {
        int step = 0;
        while (true) {
            num = 0;
            test();
            System.out.println(++step);
            if (num != 5) {
                System.out.println("num: " + num);
                break;
            }
        }
    }

    public static void test() throws InterruptedException {
        T1 t1 = new T1();
        T1 t2 = new T1();
        T1 t3 = new T1();
        T1 t4 = new T1();
        T1 t5 = new T1();
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
        t1.join();
        t2.join();
        t3.join();
        t4.join();
        t5.join();
    }
}

同时开五个线程,期待结果是五个线程运行后,num的结果为5。如果多次运行的话,就会体现出没有同步措施的可怕性。在我本机上,运行到5000次左右,会出现i=4的情况,所谓千里之堤毁于蚁穴,在计算机这么严谨的世界,小误差可能出现大问题。


Java中自然提供了同步机制,我们先从最简单的synchronized关键字说起。
synchronized关键字为Java提供了强制原子性的内部锁,什么是原子性呢?简单说,就是单独的,不可分割的操作。
一个synchronized块有两部分:锁对象的引用,以及这个锁保护的代码块。
先把上面的例子改成线程安全的,在分析synchronized的用法:

    public static class T1 extends Thread {

        public void run() {
                        synchronized(this)
                num++;
        }

    }

可以看到这里的锁对象是this,也就是当前的实例对象,而代码块是num++
第一次看到这种写法,虽然大概知道他是干啥的,不过还是会有点朦胧。可以这么理解,synchronized关键字的隐含逻辑是:num++之前当前线程对这片区域(代码块)加锁,这就告诉了其他线程,这一片暂时由我管控,诸位请稍等;num++之后,当前线程释放了区域控制权,请其他大佬们获得控制权。当然,这种逻辑在计算机世界中切换的会非常快速。
回到专业方面,锁对象也是可以是其他对象,比如如果写成synchronized(T1.class)也可以,那么锁对象就变成了是T1的Class对象。

还有另外两种方式,分别是指定一个独立的锁对象或synchronized直接修饰方法:

    private static Object object = new Object();

    public static class T1 extends Thread {
        public void run() {
            synchronized (object) {
                num++;
            }
        }
    }
        //或
    public static class T1 extends Thread {

        public synchronized void run() {
            num++;
        }

    }

用synchronized修饰方法的话,锁对象也就是当前的实例对象。
synchronized内部锁扮演了互斥锁的角色,意味着至多有一个线程可以拥有锁。
如果感兴趣,可以了解一下synchronized的实现原理,推荐文章:死磕Java并发:深入分析synchronized的实现原理

1、可重入

关于synchronized关键字,还有多介绍一个可重入的概念,先看代码:

class Widget {
    public synchronized void doSomething() {
            ...
    }
}

class LoggingWidget extends Widget {
    @Override
    public synchronized void doSomething() {
        System.out.println("logging do something");
        super.doSomething();
    }
}

调用LoggingWidget .doSomething()的时候,线程获得了Widget 实例对象的锁,LoggingWidget .doSomething()中又调用了Widget .doSomething(),因为Widget 实例对象的锁已经被占用,那么这时候会不会发生死锁呢,有可能编程了永久等待的情况。
为了解决上面的问题,引入可重入的概念:内部锁是可重进入的,线程在试图获得它自己占有的锁时,请求会成功。
重进入是基于每线程,而不是每调用的。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
待兔 待兔
3个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Wesley13 Wesley13
3年前
4、jstack查看线程栈信息
1、介绍利用jps、top、jstack命令找到进程中耗时最大的线程,以及线程状态等等,同时最后还可以显示出死锁的线程查找:FoundoneJavaleveldeadlock即可1、jps获得进程号!(https://oscimg.oschina.net/oscnet/da00a309fa6
Wesley13 Wesley13
3年前
Java日期时间API系列31
  时间戳是指格林威治时间1970年01月01日00时00分00秒起至现在的总毫秒数,是所有时间的基础,其他时间可以通过时间戳转换得到。Java中本来已经有相关获取时间戳的方法,Java8后增加新的类Instant等专用于处理时间戳问题。 1获取时间戳的方法和性能对比1.1获取时间戳方法Java8以前
Stella981 Stella981
3年前
Python之time模块的时间戳、时间字符串格式化与转换
Python处理时间和时间戳的内置模块就有time,和datetime两个,本文先说time模块。关于时间戳的几个概念时间戳,根据1970年1月1日00:00:00开始按秒计算的偏移量。时间元组(struct_time),包含9个元素。 time.struct_time(tm_y
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
03.Android崩溃Crash库之ExceptionHandler分析
目录总结00.异常处理几个常用api01.UncaughtExceptionHandler02.Java线程处理异常分析03.Android中线程处理异常分析04.为何使用setDefaultUncaughtExceptionHandler前沿上一篇整体介绍了crash崩溃
Stella981 Stella981
3年前
Noark入门之线程模型
0x00单线程多进程单线程与单进程多线程的目的都是想尽可能的利用CPU,减少CPU的空闲时间,特别是多核环境,今天咱不做深度解读,跳过...0x01线程池锁最早的一部分游戏服务器是采用线程池的方式来处理玩家的业务请求,以达最大限度的利用多核优势来提高处理业务能力。但线程池同时也带来了并发问题,为了解决同一玩家多个业务请求不被
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。