Java基础整理-多线程基础(上)

  1. 什么是线程
  2. 中断线程
  3. 线程状态
  4. 线程属性
  5. 同步

1、什么是线程

【前言】:对于多线程部分,首先得提到两个概念,就是线程和进程。
撇开计算机组成 原理、操作系统等专业课中详细而复杂的概念,我们可以这样理解,“进程”就是一个“进行中的程序”,“进行中”存在于开始和结束之间,是一次完整的执行过程。“线程”就是存在于一条“线”中的程序,“线”给人的印象是什么,独立、有一定长度、轻量,所以“线程”是一个独立的小任务,相比之下,进程是一个大活动。
可能听起来更绕了,总而言之,线程足够“细小”,可以承担编程工作中“轻量”而“繁杂”的工作。当然,前提是相互之间要协作好,就像人的两只手一样 。

线程在Java中的应用过程:

//1)实现Runnable接口的run()方法,将要使用多线程执行的任务代码放入run()方法中
public class ThreadTask implements Runnable{
    @Override
    public void run() {
        //想要使用多线程执行的任务
        ThreadTask();
    }

    public void ThreadTask(){

    }
}

//2)创建Thread对象,接收存放于Runnable变量的具体实现对象
Runnable  threadTask = new ThreadTask();
Thread thread = new Thread(threadTask);

//3)启动线程
thread.start();

【注意】:也可以通过继承Thread类覆盖Thread类中的run()方法后启动线程,但是由于继承的硬伤所以不再推荐

【总结】:通过Thread类的start()方法包装执行任务代码的目的,是为了让任务代码能够独立的并发执行,而不是先后的重复执行。

2、 中断线程

【前言】:Java基础整理是我基于自己通读《Java核心技术》后的理解来写的,在该书多线程这章,首先用来展示多线程的是一个弹球小程序,当时很不理解,为什么要搞个有点“花哨”的图形化程序做开篇展示,要知道,很多Java开发是不学Swing的。
后来突然明白,作者的用意很简单,在工作生产中,我们编码执行的任务都是连贯的、有一定的过程的,可不是输出一行“helloWorld”这样瞬间就结束了的。
任务执行过程中可能有参数传入、条件判断、结果输出等,但是这些都是取决于过程组成部分(数据、编码逻辑)本身,一旦任务开始执行,我们只能等待执行结果,或者打个断点、加个打印跟踪其执行过程,就像老婆生孩子(我未婚,只是打个可能不太恰当的比方),你可以在手术室外焦急等待,也可以在手术室内加油鼓劲,但是你不能对医生或你老婆说“咱不生了!过程太艰辛了!”。
所以线程终止就是任务执行方法的终止,发生在return方法返回、异常出错或执行完语句后。

【注意】:Java早期版本的stop方法可以被其他线程调用以强制终止线程,但是已经被弃用了。(原因:)

在Java中“请求”终止线程的方式(注意是请求):
每一个线程都具有一个boolean标志,叫做“中断状态”,默认为false,可以通过调用isInterrupted()方法获得,当对一个线程调用interrupt()方法的时候,线程的中断状态被置位,也就是被改为true。通过这个interrupt和isInterrupted字面可以看到,这个“中断状态”就是一个通知介质,表示这个线程有没有“被请求中断”。

【示例】:

thread.start();// 启动线程
boolean isTrue = thread.isInterrupted();// 获取该线程的“中断状态”
System.out.println("isTrue? " + isTrue);// False
thread.interrupt();// 请求中断 该线程
boolean isTrueYet = thread.isInterrupted();// 再次获取该线程的“中断状态”
System.out.println("isTrueYet? " + isTrueYet);// True

但是,如果线程被阻塞(调用sleep或wait),就无法检测中断状态,就是获取不到这个“中断状态”,获取不到也就无法更改,所以这时interrupt和isInterrupted的调用都无效,会抛出InterruptedException。
同样,“中断状态”被置位(更改为true)时,调用sleep也会抛出同样的异常,并且会清除这一状态(重新改为默认的false)。

【示例】:

thread.start();// 启动线程
//thread的run()方法中会调用sleep()
thread.interrupt();//抛出异常
thread.isInterrupted();//抛出异常

【注意】:与实例方法isInterrupted()类似的有一个Thread类的静态方法interrupted(),区别在于静态方法interrupted()会清除现成的中断状态。

【总结】:run()方法中要利用“中断状态”或sleep()方法来控制线程运行时,要充分考虑到这两者的冲突状况,使用tryCatch语句捕获异常并做后续处理,例如使用sleep()方法后捕获到异常,说明有中断请求,此时sleep()被中断,可以在catch语句中再次调用interrupt()以达到中断效果。当然,更好的方式是将异常以throws方式传递给调用者,以达到调用过程中具体问题具体分析的目的。

3、线程状态

线程可以有如下6种状态(可能有点绕,过一下概念):

  • New(新创建):new Thread(new Runnable()…)创建后还未执行start()
  • Runnable(可运行):执行start()后视操作系统提供的资源而定,可能处于运行状态,也可能没有运行
  • Blocked(被阻塞):线程试图获取一个内部的对象锁,而该锁被其他线程持有,该线程进入阻塞状态
  • Waiting(等待):等待另一个线程通知调度器一个条件
  • Timed waiting(计时等待):有超时参数的等待,不是无时间限制等待
  • Terminated(被终止):执行结束或抛出异常

4、线程属性

线程属性涉及三个场景概念:

  • 线程优先级:
    默认情况下,一个线程继承它的父线程的优先级。
    设置线程优先级方法:setPriority(int newPriority);
    优先级静态常量:
    static int MIN_PRIORITY,NORM_PRIORITY,MAX_PRIORITY(字面意思能够看到分别是最小、默认、最大优先级,对应int值分别是1,5,10)
    让步方法:yield(),让其他优先级高的线程先运行

  • 守护线程:
    设置守护线程方法:setDaemon(boolean isDaemon)
    守护线程的唯一用途是为其他线程提供服务,比如计时器线程。当只剩下守护线程时,虚拟机退出,因为此时就没有必要继续运行程序了。
    【注意】:setDaemon()方法必须在线程启动之前调用,守护线程应该永远不去访问固有资源,因为守护线程可能会因为主线程(被守护线程)退出而终止。

  • 未捕获异常处理器:
    线程的run()方法不能抛出任何被检测的异常,但是,不被检测的异常会导致线程终止。
    但是,不需要catch子句来处理“可传播”异常,线程死亡之前,异常被传到一个用于未捕获异常的处理器。处理器类须实现Thread.UncaughtExceptionHandler接口uncaughtException(Thread t,Throwable e)方法。
    为线程安装处理器方法:setUncaughtExceptionHandler()
    为所有线程安装默认处理器方法:setDefaultUncaughtExceptionHandler()
    默认处理器可以为空,独立的线程如果没有安装处理器,此时处理器就是该线程的ThreadGroup对象,所以引入了“线程组”概念。

  • 线程组:
    线程组是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组,但是,也可能建立其他的组。现在引入了更好的特性用于线程集合的操作(线程池),所以说了这么多,过一下概念就好,不建议使用线程组。
    作为独立线程的默认处理器,
    所以线程组ThreadGroup也实现Thread.UncaughtExceptionHandler接口,它的uncaughtException()方法操作顺序如下:
    a.如果该线程组有父线程组,就执行父线程组的uncaughtException()方法(看来线程组默认继承父类处理方法)
    b.如果默认处理器非空,则调用该处理器(没有父类就寻求现成处理器)
    c.如果传入的Throwable是ThreadDeath实例,则什么都不做(让线程自然死亡)
    d.线程名字及Throwable的栈踪迹被输出到System.err上

5、同步

《Java核心技术》第14章一开始介绍“多进程”与“多线程”的时候,说他们的本质区别在于每个进程拥有自己的一整套变量,而线程则共享数据。这种说话,真的很书本化,让人觉得很有道理,但是又有一点稀里糊涂。
我个人是这样理解的,“进程“是完整的任务,“进程”与“进程”之间没有什么太大的关系,因为执行不同“进程”的代码是独立的。
但是“线程”不一样,执行“线程”的代码是同一段,就是实现Runnable接口run()方法中的那一段代码。让同一段代码在“多线程”上并发运行,是为了利用计算机资源更快的完成“大工作量”任务,而这个“大工作量”是针对同样的参数,也就是这里所说到的被共享的数据。(好了到此为止,再扯下去就绕了)

【竞争条件】:
首先认识一个概念,“竞争条件”,是指多线程调用同一个修改对象的方法,对同一数据进行存取操作,可能会产生讹误对象的情况。(类似大家在日常工作中,不更新本地代码而直接提交SVN,将同事代码覆盖,发生“误伤”友军的情况)

那么产生“竞争条件”现象的问题本质是什么呢?其实就是代码中的操作并非原子操作,比如取值赋值,可能体现在代码中只是一个简短的赋值等式,例如a+=b,组成该等式的计算机机器指令可能有三个(加载a到寄存器,增加b,将结果写回到a,实际步骤可能更多)。很可能结果还没写回到a,线程就被中断然后进行其他赋值操作,当有机会执行写回到a时,前面的其他赋值操作就被覆盖了,产生了讹误。就像你下载了SVN上的代码,修改后正准备提交,其他人先你一步更改并提交了,当你提交后,别人提交的代码就很不幸地被你覆盖了。

【锁对象】:
为了解决以上所述的并发访问相同数据的问题,Java提供了一个synchronized关键字,同时Java SE 5.0 引入了ReentrantLock类。如果百度搜下Reentrant这个单词,会发现它的中文意思是“折返“,形象的指代了加锁代码块并发执行时不再交替运行,而是”折返“运行,一个线程完整执行完后,再”折返“回去,让另一个线程再次执行,实现被加锁代码块执行时的原子性。

《Java核心技术》上的代码比较繁琐,以下是简单点的示例代码:

public class LockTest{
    public static void main(String[] args) {
        Runnable r = new LockThread ();
        Thread t = new Thread(r);//第一个线程
        t.start();
        Thread t1 = new Thread(r);//第二个线程
        t1.start();

    }
}

class LockThread implements Runnable{
    private int i=0;//声明一个共享数据
    private Lock testLock = new ReentrantLock();//声明一个锁
    @Override
    public void run(){
        String lock=Double.toString(Math.random()*10000);//随机数是为了分辨属于同一线程的操作
        System.out.println("获取锁"+lock);
        testLock.lock();//在需要原子性执行的代码块前加锁
        try{
            int j = 0;
            while(j<5){
                String key = Double.toString(Math.random()*10000);//随机数是为了分辨属于同一循环的操作
                System.out.println("第"+key+"次开始:"+i);
                ++i;
                System.out.println("第"+key+"次结束:"+i);
                j++;
            }
        }finally{
            testLock.unlock();//执行完后解锁
            System.out.println("释放锁"+lock);
        }
    }
}

【注意】:Lock.lock()方法获取锁,如果锁被其他线程拥有,则发生阻塞。ReentrantLock构造函数还可以有个boolean参数,构建带有公平策略的锁,控制是否偏爱等待时间最长线程,但是性能较差,不建议使用。

【条件对象】:我们可以利用锁对象的加锁、解锁来实现目标代码块的原子操作,这里提到的目标代码块也就是临界区。当线程成功获取锁,进入临界区开始执行的时候,经常发现需要满足某些条件后才能成功执行,毕竟所谓的“业务逻辑”在编码层面不就是一个个判断条件组成的分支流程嘛。
这个时候有两种解决方式,第一种是让代码顺其自然,不满足条件有不满足条件的返回(通常是跳过然后直接结束),但是这样会带来一个问题,那就是执行效率低下,多线程竞争资源获取锁本身就十分耗时、耗资源,如果经常出现线程获取锁后由于条件不满足而什么也不干,那么就违背了多线程“充分利用系统资源,高效并发执行任务”的设计目的和初衷了。
所以,第二种方式就是利用条件对象来管理已经获得了锁但是却不能做有用工作的线程。当条件不满足时,阻塞当前线程,等待条件满足时由其他线程唤醒,这样的等待是有针对性的,而不是盲目地去竞争锁的所有权,这样才能确保当前线程所承担的任务最终得到执行而不是跳过。

以下代码须执行多次,模拟变量“i”值不够大时等候其他线程再次对其加上随机数后唤醒再执行的过程。

public class ConditionTest {
    public static void main(String[] args) {
        Runnable r = new ConditionThread();
        Thread t1=new Thread(r);
        t1.start();
        Thread t2=new Thread(r);
        t2.start();
    }
}

class ConditionThread implements Runnable{
    int i=0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    @Override
    public void run(){
        lock.lock();
        try{
            String key = Double.toString(Math.random());
            System.out.println("第"+key+"次开始:"+i);
            i+=Math.random()*10;//i加个随机数
            System.out.println("第"+key+"次中间:"+i);
            while(i<4){//用while很重要,因为被唤醒后要再次判断条件
                System.out.println("第"+key+"次执行条件阻塞,等待i>=4");
                condition.await();//如果i加随机数后小于4就阻塞条件,如果第二次还是<4,就死锁了。。两个线程都阻塞了
            }
                i--;//如果i加随机数大于等于4,则自减并唤醒该条件,最后输出
                condition.signalAll();
                System.out.println("第"+key+"次结束并释放条件:"+i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally{
            lock.unlock();
        }
    }
}

【注意】:signal()方法是随机唤醒该条件等待集中的一个线程,解除其阻塞状态,不保险。。
【总结】:1、锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码(保证目标代码执行的原子性)2、锁可以管理师团进入被保护代码段的线程(阻塞这个线程)3、锁可以拥有一个或多个相关的条件对象(从条件对象的实例化就可以看出,条件对象是关联在锁上面的)4、每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程(不满足条件)

【synchronized关键字】:
Lock对象、Condition对象真的很形象,开发人员可以利用Lock和Condition实现基于各种运行条件之上的原子运行目标代码块。只是多数情况下,并发执行的代码都比较基础,条件没有那么多,通常一个就够了。而synchronized关键字基于Java语言内部的机制,那就是每个一个对象都有一个内部锁,是不是很酷,也就是说每一个对象都有一个天生的锁,而这个锁天生还带有一个条件,所以当我们只需要一个锁,一个条件的时候,我们不用再手动创建锁对象及相关联的条件,而直接使用内部锁就可以了。

synchronized method(){//有了synchronized关键字,这个代码同一时间只能被单个线程执行
    while(!condition){
        wait();//条件不满足时,将当前线程阻塞,加入到内部锁的条件的等待集里去    
    }
    do tasks;//条件满足时,执行任务代码
    notifyAll();//通知所有等待条件的线程,接触他们的阻塞状态
}

【同步阻塞】:
获取一个对象的锁,就制造了一个同步阻塞,什么叫同步阻塞,可以这样理解,一旦同步,就阻塞,说白了就是不允许同步,得一个一个来。
前面是通过用synchronized关键字修饰方法来实现一个同步方法,调用同步方法时获得锁,因为类的实例对象在调用synchronized关键字修饰的同步方法时,在方法块的开始自动获取该对象的锁,结束时自动释放该对象的锁。
同步方法的作用于只限于被声明的方法,而synchronized关键字可以直接对对象使用,直接获取该对象的锁以及锁的释放。作用域不仅限于方法块,而是synchronized(object){}所包含的块,它的范围比方法更广,因为它可以包含方法。

synchronized method(){
    do task...//同步方法作用域
}

synchronized(obj){
    task & method//synchronized(obj)作用域
}

每个对象都有一个内部锁,所以可以利用这个特性,为创建任意对象用作锁对象

public class ClassName{
    private Object lock = new Object();//每个ClassName的实例对象都有一个唯一的lock对象
    public void method(){
        synchronized(lock){
            task...//如果调用同一个ClassName实例对象的method方法多次,因为ClassName对象有一个lock对象,所以每次都需要获取lock对象的锁,所以实现了方法的同步原子性
        }
    }

    public synchronized void sameMethod(){
        task...//效果和这个方法一样,只不过上面用了lock对象的内部锁,这个方法用了ClassName对象的内部锁
    }
}

【客户端锁定】:方法可以是同步的,但是如果一个任务需要好几个方法根据需要组合在一起,还要实现原子性执行。简单的拼接在一起是不行的,因为方法与方法之间是独立的,方法执行结束会自动释放对象的锁。
客户端是什么,客户端就是实际调用方法的地方,客户端的需求就是接收对象并调用对象的方法,客户端也有自己的原子性任务执行需求,所以客户端可以手动获取对象的锁,在执行完要执行的方法后,再释放对象的锁,以达到原子性执行任务的目的。

public void userLockExcute(Object obj){
    synchronized(obj){ //手动获取对象的锁
        obj.method1();//所获取的锁传递给method1()
        obj.method2();//所获取的锁传递给method2()
        ...//调用完所需方法后自动释放,实现客户端锁定对象,达到原子执行任务的目的
    }
}

在这个示例代码中,有一个锁传递的概念(这个是我自己的联想),所以前提是method1()和method2()都是同步方法,不然其他地方调用method1()或者method2()还是引发竞争,这样就没有办法保证在锁的保护内将所有方法执行结束。

【注意】:客户端锁定还是十分脆弱的,因此不建议使用,但是理解整个锁定的过程十分有利我们对多线程调度的理解

【监视器概念】:锁和条件是线程同步的强大工具,到现在,我们对“线程同步”已经有一个十分清晰的认识了,我的脑子里是这样的一幅画面,多线程固然方便,可以并行运行,但是对于需要原子操作的部分,大家(这里指线程)得一个一个来,不要发生讹误。而Java中每个对象都有一个内部锁,每个内部锁都有一个内部条件,这样的设计真的很赞,不仅仅是简化了锁对象、条件对象的使用,而是让锁、条件与对象关联,让本身面向过程(在调用过程中严格控制加锁、解锁)的锁、条件变成面向对象(获取、释放目标单一对象锁),这样的设计理念来自于“监视器”概念。

“监视器”特性:
1、只包含私有域(只能通过对象更改,埋伏笔)
2、每个对象有一个相关的锁(实现对象同步操作,间接保护了私有域)
3、对象锁对所有方法加锁(保证操作原子性)
4、锁有任意多个相关条件(保证灵活性)

Java对象不同于监视器之处:
1、域不要求必须是private
2、方法不要求必须是synchronized
3、内部锁对客户可用(synchronized(obj) 使用方式)

【Volatile域】:
为了读写一个或两个实例域就使用同步显得开销太大,所以得厘清可能出错的地方。
有两点值得注意,第一点是多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值,运行在不同处理器上的线程可能在同一内存位置取到不同的值。
第二点是编译器可以改变指令执行的顺序以使吞吐量最大化,编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变,但是内存的值可以被另一个线程改变。
锁的作用就是在必要的时候刷新本地缓存并且不能不正当地重新排序指令以达到原子性操作。
而volatile关键字为实例域的同步访问提供了一种免锁机制,如果声明一个域为volatile,那么编译器和虚拟机就知道该域可能被另一个线程并发更新。
但是volatile只提供了保证访问该变量时,每次都是从内存中读取最新值,并不会使用寄存器缓存该值。而对该变量的修改,volatile并不提供原子性的保证。那么编译器究竟是直接修改内存的值,还是使用寄存器修改都符合volatile的定义。
所以直接赋值操作是可以的,但是自增或者翻转这类操作不能保证原子性,因为他们具体的机器指令有多条,不过java.util.concurrent.atomic包中很多类使用了高效机器级指令提供原子操作方法(轮子程序员专用?)。

private volatile boolean done;
public boolean isDone(){return done;}
public void setDone(){done = true;}
public void setDone(){done = !done;}//这个操作很显然需要先取出done再取反,最后赋值,同时执行依然会产生讹误

总之,volatile的作用是多线程安全读取一个域的值,当然,还有另一种情况可以安全地访问一个共享域,那就是final变量。

【死锁】:就是大家都阻塞了,前面的例子中就可能会出现此情况,所有的线程都需要某条件而阻塞等待。所以要仔细设计控制流程,保证至少有一个线程不会被阻塞。

【线程局部变量】:
共享变量的意义在于线程之间的交互沟通以及协同工作目的的一致性,但有时要避免实用共享变量,而ThreadLocal辅助类为各个线程提供各自的实例。

public class ThreadLocalTest {
    public static final ThreadLocal<SimpleDateFormat> dateFormat=new ThreadLocal<SimpleDateFormat>(){
        protected SimpleDateFormat initialValue(){//覆盖此方法提供一个初始值
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

    public static void main(String[] args) {
        String dateStamp = dateFormat.get().format(new Date());//首次调用get会调用initialize来得到这个值
        System.out.println(dateStamp);
    }
}

在一个给定线程中首次调用get时,会调用initialValue方法,在此之后,get方法会返回属于当前线程的那个实例。

【锁测试与超时】:这里引入了一个比较灵活的方法tryLock(),该方法试图申请一个锁,成功获得锁后返回true,否则立即返回false,而且线程可以立即离开去做其他事情,而不是lock()方法那样一直阻塞着。
lock()方法不能被中断,为什么呢,因为中断是“请求机制的”,你去请求一个正在“等待”锁的线程,那么在他等到锁之前,他是不会响应你的,作为“中断线程”,你也将一直处于阻塞状态,如果出现死锁,lock()方法就永远无法响应、无法终止,“中断线程”也死锁了。
但是使用带有超时参数的tryLock()方法,当被中断时,会抛出InterruptedException异常,允许其他线程打破死锁,同时,await()方法也允许被打断。

【读写锁】:java.util.concurrent.locks包定义了两个锁类,一个是ReentrantLock类,前面使用到,该类拓展了Lock类,可以指定锁对象是否公平。另一个是ReentrantReadWriteLock类,适用于多数读操作少数写操作的。

private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();//构造读写锁对象
private Lock readLock = rwl.readLock();//获取读锁,用于对所有获取方法加锁
private Lock writeLock = rwl.writeLock();//获取写锁,用于对所有更改方法解锁

读锁排斥所有写操作,写锁排斥所有读写操作,所以比较适用于读操作多的程序。

【为什么弃用stop和suspend方法】:stop()方法强制中止线程,suspend()方法强制阻塞线程。
中止会释放对象所持有的锁,阻塞将等待被恢复而不释放锁。
会导致两种情况:
1、当前线程在执行中被中断释放锁,其他线程中该对象继续执行,导致对象产生讹误,主要原因是在线程未完成原子操作的时候被中断了
2、被阻塞线程未释放锁,导致该锁永远释放不了,而调用suspend()方法将其阻塞的线程目的是阻塞目标线程后执行自己的任务,执行完以后再恢复它,但是如果调用suspend()方法的线程需要获取被阻塞线程的锁,那么这个线程也将阻塞,程序就死锁了。当然,可以使用“旗标”设计方式来实现阻塞请求,就像请求中断一样。

private volatile boolean suspendOrStopRequested = false;//可以写个方法来更改它的值以达到请求中止和恢复的功能

public void run(){
    boolean isDone=true;
    while(isDone){//循环判断
        if(suspendOrStopRequested){//在安全的时候处理其他线程的中断或者阻塞请求,比如执行完原子操作
            isDone=false;//请求中断可以跳过等待,请求中断可以直接结束线程
        }else{
            do lock task...
        }
    }
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值