Java日常学习总结并发进阶之线程初步、锁与同步篇

2019-08-23  本文已影响0人  WeiTanOri

线程状态

线程状态.png

新创建

刚new出来的Thread还没有被运行

可运行

一旦调用start 方法,线程处于runnable状态。一个可运行的线桿可能正在运行也可能没有运行, 这取决于操作系统给线程提供运行的时间。

被阻塞线程和等待线程

当线程处于被阻塞或等待状态时, 它暂时不活动。它不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。细节取决于它是怎样达到非活动状态的

被终止的线程

线程因如下两个原因之一而被终止:

同一个线程被 start() 两次(2019-7-13更新)

Java 的线程是不允许启动两次的,第二次调用必然会抛出 IllegalThreadStateException,这是
一种运行时异常,多次调用 start 被认为是编程错误。在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW)状态,但是不论如何,都是不可以再次启动的。

线程状态之间相互转化.jpg

创建一个新线程的三种方法

通过Runnable接口创建线程类

  1. 定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
  2. 创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
  3. 调用线程对象的start()方法来启动该线程。
public class RunnableThreadTest implements Runnable {
    private int i;
    public void run() {
        for (i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName()+ " " + i);
        }
    }
    public static void main(String[] args) {
    for (int i = 0; i < 100; i++) {
        System.out.println(Thread.currentThread().getName()+ " " + i);
        if (i == 20) {
            RunnableThreadTest rtt = new RunnableThreadTest();
            new Thread(rtt, "新线程1").start();
            new Thread(rtt, "新线程2").start();
        }
    }
    }
}

继承Thread类创建线程类

  1. 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
  2. 创建Thread子类的实例,即创建了线程对象。
  3. 调用线程对象的start()方法来启动该线程。
public class FirstThreadTest extends Thread {
    int i = 0;
    //重写run方法,run方法的方法体就是现场执行体
    public void run() {
        for (; i < 100; i++) {
            System.out.println(getName() + " " + i);
        }
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName()+ " : " + i);
            if (i == 20) {
                new FirstThreadTest().start();
                new FirstThreadTest().start();
            }
        }
    }
}

Callable 接口

Callable 与 Runable 有两点不同:

Thread 和 Runnable 的区别

如果一个类继承 Thread, 则不适合资源共享。但是如果实现了 Runnable 接口的话,则很容易实现资源共享。

总结:
实现 Runnable 接口比继承 Thread 类具有的优势:

  1. 适合多个和相同的程序代码的线程去共享同一个资源。
  2. 可以避免 java 中的单继承的局限性。
  3. 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。
  4. 线程池只能放入实现 Runnable或 Callable 类线程,不能直接放入继承Thread 的类。

中断线程

没有任何语言方面的需求要求一个被中断的线程应该终止。中断一个线程不过是引起它的注意。被中断的线程可以决定如何响应中断。某些线程是如此重要以至于应该处理完异常后, 继续执行, 而不理会中断。但是,更普遍的情况是,线程将简单地将中断作为一个终止的请求。

线程属性

线程优先级

每当线程调度器有机会选择新线程时,它首先选择具有较高优先级的线程。但是,线程优先级是高度依赖于系统的。当虚拟机依赖于宿主机平台的线程实现机制时,Java 线程的优先级被映射到宿主机平台的优先级上,优先级个数也许更多,也许更少。

static void yield( )
导致当前执行线程处于让步状态。如果有其他的可运行线程具有至少与此线程同样高的优先级,那么这些线程接下来会被调度。注意,这是一个静态方法。

守护线程(2019-7-13更新)

有的时候应用中需要一个长期驻留的服务程序,但是不希望其影响应用退出,就可以将其设置为守护线程,如果 JVM 发现只有守护线程存在时,将结束进程,具体可以参考下面代码段。注意,必须在线程启动之前设置。

Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();

同步

为了避免多线程引起的对共享数据的讹误,必须学习如何同步存取。

竞争条件详解

当两个线程试图同时更新同一个账户的时候,这个问题就出现了。假定两个线程同时执行指令accounts[to] += amount;问题在于这不是原子操作。该指令可能被处理如下:

  1. accounts[to]加载到寄存器。
  2. 增加amount
  3. 将结果写回accounts[to]

现在,假定第1个线程执行步骤1和2, 然后,它被剥夺了运行权。假定第2个线程被唤醒并修改了accounts 数组中的同一项。然后,第1个线程被唤醒并完成其第3步。
这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。
因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。

锁同步

锁Lock

有两种机制防止代码块受并发访问的干扰。Java语言提供一个 synchronized 关键字达到这一目的,并且Java SE 5.0引入了ReentrantLock 类。

public class Bank{
    private Lock bankLock = new ReentrantLock0 ;// ReentrantLock implements the Lock interface
    public void transfer(int from, intto, int amount){
        bankLock.lock();
        try
        {
            System.out.print(Thread.currentThread0);
            accounts[from] -= amount;
            System.out.printf(" %10.2f from %A to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(" Total Balance: %10.2f%n",getTotalBalance());
        }
        finally
        {
            banklock.unlockO;
        }
    }
}

重入Lock是一个更强大的工具,他有一个重入功能————当一个线程得到一个对象后,再次请求该对象锁时是可以再次得到该对象的锁的。
具体概念就是:自己可以再次获取自己的内部锁。因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(holdcount) 来跟踪对lock 方法的嵌套调用。线程在每一次调用lock 都要调用unlock 来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。
假定一个线程调用transfer, 在执行结束前被剥夺了运行权。假定第二个线程也调用transfer, 由于第二个线程不能获得锁,将在调用lock 方法时被阻塞。它必须等待第一个线程完成transfer 方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行

公平锁CPU在调度线程的时候是在等待队列里随机挑选一个线程,由于这种随机性所以是无法保证线程先到先得的(synchronized控制的锁就是这种非公平锁)。但这样就会产生饥饿现象,即有些线程(优先级较低的线程)可能永远也无法获取CPU的执行权,优先级高的线程会不断的强制它的资源。那么如何解决饥饿问题呢,这就需要公平锁了。公平锁可以保证线程按照时间的先后顺序执行,避免饥饿现象的产生。但公平锁的效率比较低,因为要实现顺序执行,需要维护一个有序队列。

条件对象Condition
假定一个线程已经获得锁,将要执行,但是他所需要的条件还没有满足(例如在余额不足的情况下取钱),便会造成有锁却不执行,其他能够提供满足条件的线程(例如存钱)却只能等待,陷入僵局。
一个锁对象可以有一个或多个相关的条件对象。你可以用newCondition 方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“ 余额充足”条件。

class Bank{
    private Condition sufficientFunds;
    ···
    public Bank(){
    ···
    sufficientFunds=bankLock.newCondition();
    }
}

如果transfer方法发现余额不足,它调用下面这个方法
sufficientFunds.await();
当前线程现在被阻塞了,并放弃了锁。我们希望这样可以使得另一个线程可以进行增加账户余额的操作。等待获得锁的线程和调用await 方法的线程存在本质上的不同。一旦一个线程调用await方法,它进人该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll 方法时为止。

synchronized关键字

在前面一节中,介绍了如何使用 Lock 和 Condition 对象。在进一步深人之前,总结一下有关锁和条件的关键之处:

Lock 和 Condition 接口为程序设计人员提供了高度的锁定控制。然而,大多数情况下,并不需要那样的控制,并且可以使用一种嵌人到Java 语言内部的机制。

如果一个方法用synchronized 关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说

public synchronized void method()
{
    method body
}

其实方法锁其实锁的是实例对象,可以等价于

public void method(){
    //相当于锁实例
    synchronized(this){
        //需要同步的代码块
    }
}

将静态方法声明为synchronized 也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bankxlass对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。

public static void method(){
    //相当于锁的整个类
    synchronized(xxx.class){
        //需要同步的代码块
    }
}

静态方法同步与非静态方法同步区别:

内部锁和条件存在一些局限。包括:

synchronized和ReentrantLock的比较

区别:

  1. Lock是一个接口,是通过 JDK 来实现的,而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现,是 JVM 实现的;
  2. synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock() 去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
  3. Lock可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  4. 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
  5. Lock可以提高多个线程进行读操作的效率。

两者在锁的相关概念上区别:

  1. 可中断锁
    顾名思义,就是可以响应中断的锁。
    在Java中,synchronized就不是可中断锁,而Lock是可中断锁。如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。
    lockInterruptibly()的用法体现了Lock的可中断性。
  2. 公平锁
    公平锁即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该锁(并不是绝对的,大体上是这种顺序),这种就是公平锁。
    非公平锁即无法保证锁的获取是按照请求锁的顺序进行的。这样就可能导致某个或者一些线程永远获取不到锁。
    在Java中,synchronized 就是非公平锁,它无法保证等待的线程获取锁的顺序。ReentrantLock可以设置成公平锁。
  3. 读写锁
    读写锁将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁。
    正因为有了读写锁,才使得多个线程之间的读操作可以并发进行,不需要同步,而写操作需要同步进行,提高了效率。
    ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。
    可以通过readLock()获取读锁,通过writeLock()获取写锁。
  4. 绑定多个条件
    一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多余一个条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这么做,只需要多次调用new Condition()方法即可。

在新版的 JDK 中, synchronize 也逐渐有了很多优化,除非我们需要用到 ReentrantLock 的高级功能(比如上述几个锁),我们尽量选用 synchronize 关键词。

final

还有一种情况可以安全地访问一个共享域,即这个域声明为final 时。考虑以下声明:

final Map<String, Double〉accounts = new HashMap<>() ;

其他线程会在构造函数完成构造之后才看到这个accounts变量。

线程间协作

join

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。

wait、notify、notifyall

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

它们都属于 Object 的一部分,而不属于 Thread。

只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException。

使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

死锁

产生条件

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

有3种典型的死锁类型:

静态的锁顺序死锁

a和b两个方法都需要获得A锁和B锁。一个线程执行a方法且已经获得了A锁,在等待B锁;另一个线程执行了b方法且已经获得了B锁,在等待A锁。这种状态,就是发生了静态的锁顺序死锁。

经典面试问题:写一个死锁

class StaticLockOrderDeadLock{
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void a(){
        synchronized(lockA){
            synchronized(lockB){
                System.out.println("function a");
            }
        }
    }

    public void b(){
        synchronized(lockB){
            synchronized(lockA){
                System.out.println("function b");
            }
        }
    }
}

解决静态的锁顺序死锁的方法就是:所有需要多个锁的线程,都要以相同的顺序来获得锁。

动态的锁顺序死锁

动态的锁顺序死锁是指两个线程调用同一个方法时,传入的参数颠倒造成的死锁。
如下代码,一个线程调用了transferMoney方法并传入参数accountA,accountB;另一个线程调用了transferMoney方法并传入参数accountB,accountA。此时就可能发生在静态的锁顺序死锁中存在的问题,即:第一个线程获得了accountA锁并等待accountB锁,第二个线程获得了accountB锁并等待accountA锁。

动态的锁顺序死锁解决方案如下:使用System.identifyHashCode来定义锁的顺序。确保所有的线程都以相同的顺序获得锁。

协作对象之间发生的死锁

有时,死锁并不会那么明显,比如两个相互协作的类之间的死锁,比如下面的代码:一个线程调用了Taxi对象的setLocation方法,另一个线程调用了Dispatcher对象的getImage方法。此时可能会发生,第一个线程持有Taxi对象锁并等待Dispatcher对象锁,另一个线程持有Dispatcher对象锁并等待Taxi对象锁。

上面的代码中,我们在持有锁的情况下调用了外部的方法,这是非常危险的(可能发生死锁)。为了避免这种危险的情况发生,我们使用开放调用。如果调用某个外部方法时不需要持有锁,我们称之为开放调用。解决协作对象之间发生的死锁:需要使用开放调用,即避免在持有锁的情况下调用外部的方法

锁优化

多线程

更多关于Java并发多线程请点击Java进阶学习多线程

上一篇下一篇

猜你喜欢

热点阅读