Java 基础(十三)线程——上

1,262 阅读17分钟

今天开始正式进入线程的学习。

Java 线程:线程的概念和原理

操作系统中的线程和进程的概念

现在的操作系统都是多任务的操作系统,多线程是多任务的一种方式。

进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间。一个进程可以启动多个线程。

线程是指进程中的一个执行流程,一个进程中可以运行多个线程。线程总是属于某个进程,进程中的多个线程共享进程的内存。

多线程“同时”执行是人的感觉,其实是线程之间轮换执行的。

Java 中的线程

在 Java 中,“线程”指两件不同的事情:

1.Thread 类的一个实例
2.线程的执行

使用 Thread 类或者 Runnable 接口编写代码来定义、实例化和启动新线程。

一个 Thread 类实例只是一个对象,和 Java 中的任何对象一样,具有变量和方法,生死与堆上。

Java 中,每个线程都有一个调用栈,即使不在程序中创建任何新的线程,线程也在后台运行着。

一个 Java 应用总是从 main 方法开始运行,main 方法运行在一个线程内,它被称为主线程。

一个创建一个新的线程,就产生一个新的调用栈。

线程总体分两类:用户线程和守护线程。

当所有用户线程执行完毕的时候,JVM 自动关闭。但是守护线程缺不独立于 JVM,守护线程一般是由操作系统或者用户自己创建的。

Java 线程:创建与启动

定义线程

  • 扩展 Thread 类
    此类中有个 run 方法,应该注意其使用方法:
    public void run
    如果该现场是使用独立的 Runnable 运行对象构造的,则调用该 Runnable 对象的 run 方法;否则,该方法不执行任何操作并返回。
    Thread 的子类应该重写该方法。

  • 实现 Runnable 接口
    使用实现接口 Runnable 的对象创建一个线程时,启动该线程将导致在独立执行的线程中调用对象的run 方法。
    方法 run 的常规协定是,它可能执行任何所需的操作。

实例化线程

  • 如果是扩展 Thread 类的线程, 直接 new 即可。
  • 如果是扩展 Runnable 类的线程,则直接 new 即可。

启动线程

在线程的 Thread 对象上调用 start 方法,而不是 run 方法。

在调用 start 方法之前:线程处于新状态中,新状态指有一个 Thread 对象,但还没有一个真正的线程。

在调用 start 方法之后,发生了一系列复杂的事情

1.启动新的执行线程(具有新的调用栈)
2.该线程从新状态转移到可运行状态
3.当该线程活得机会执行时,其目标 run 方法将允许。

注意:对于 Java 来说,run 方法没有任何特别之处。像 main 方法一样,它只是新线程知道调用的方法和名称。因此,Runnable 上或者 Thread 上调用 run 方法是合法的,但是并不会启动新线程。

举例

1.实现 Runnable 接口的多线程例子

public class TestRunnable implements Runnable {

    private final String name;

    public TestRunnable(String name) {
        this.name = name;
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new TestRunnable("李四"));
        Thread thread2 = new Thread(new TestRunnable("张三"));
        thread.start();
        thread2.start();
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
           System.out.println(Thread.currentThread().getName()+":"+name + ": " + i);
        }
    }
}

执行结果如下:

Thread-1:张三: 0
Thread-1:张三: 1
Thread-1:张三: 2
Thread-1:张三: 3
Thread-1:张三: 4
Thread-0:李四: 0
Thread-1:张三: 5
Thread-0:李四: 1
Thread-1:张三: 6
Thread-1:张三: 7
Thread-0:李四: 2
...

2.扩展 Thread 类实现的多线程例子

public class TestThread extends Thread {

    private final String name;

    public TestThread(String name) {
        this.name = name;
    }

    public static void main(String[] args) {
        Thread thread = new TestThread("李四");
        Thread thread2 = new TestThread("张三");
        thread.start();
        thread2.start();
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + name + ": " + i);
        }
    }
}

执行结果如下:

Thread-0:李四: 0
Thread-0:李四: 1
Thread-0:李四: 2
Thread-0:李四: 3
Thread-0:李四: 4
Thread-0:李四: 5
Thread-0:李四: 6
Thread-0:李四: 7
Thread-0:李四: 8
Thread-0:李四: 9
Thread-0:李四: 10
Thread-0:李四: 11
Thread-1:张三: 0
Thread-0:李四: 12
Thread-0:李四: 13
...

对于上面的多线程代码来说,输出的结果是不确定的。其中的 for 循环只是用来模拟一个耗时操作。

一些常见问题

1.线程的名字,一个运行中的线程总是有名字的,名字有两个来源,一个是虚拟机自己给的名字,一个是你自己定的名字。在没有指定线程名字的情况下,虚拟机总会为线程指定名字,并且主线程的名字总是 main,非主线程的名字不确定。
2.线程都可以设置名字,也可以获取名字,主线程也不例外。
3.获取当前线程的对象的方法是:Thread.currentThread();
4.在上面的代码中,只能保证:每个线程都将启动,每个线程都将运行直到完成。一系列线程以某种顺序启动并不意味着将按该顺序执行。对于任何一组启动的线程来说,调度程序不能保证其执行次序,持续时间也无法保证。
5.当线程目标 run 方法结束时该线程结束。
6.一旦线程启动,它就永远不能再重新启动。
7.线程的调度是 JVM 的一部分,在一个 CPU 的机器上,实际上一次只能运行一个线程。一次只能有一个线程栈执行。JVM 线程调度程序决定实际运行哪个处于可运行状态的线程。众多可运行线程中的某一个会被选中做为当前线程。可运行线程被选择运行的顺序是没有保障的。
8.尽管通常采用队列形式,但这是没有保障的。队列形式是指当一个线程完成“一轮”时,它移动到可运行队列的尾部等待,直到它最终排队到该队列的前端为止,它才能被再次选中。事实上,我们把它称为可运行池而不是一个可运行队列,目的是帮助认识线程并不都是以某种有保障的顺序执行的一个队列实例。
9.尽管我们无法控制线程调度程序,但可以通过别的方式来影响线程调度方式。

Java 线程:线程栈模型

要理解线程调度的原理,以及线程执行过程,必须理解线程模型。
线程栈是指某时刻内存中线程调度的栈信息,当前调用的方法总是位于栈顶。线程栈的内容是随着程序的运行动态变化的,因此研究线程栈必须选择一个运行的时刻。

下面通过一个示例的代码说明线程栈的变化过程。

这幅图描述在代码执行到两个不同时刻,虚拟机调用栈示意图。
当程序执行到thread.start()的时候,程序多出一个分支(增加了一个调用栈 B),这样,栈 A、栈 B 并行执行。

Java 线程:线程状态的转换

线程状态

线程的状态转换是线程控制的基础。线程状态总得来说可分为五种:分别是创建、销毁、可运行(就绪)、运行、等待/阻塞。用一张图描述如下:

我懒得画图了,从度娘那里偷了一张图,忽略“死忙”~

1.创建:线程对象已经创建,还没有调用 start 方法。
2.可运行:当现场有资格运行,但调度程序还没有把它选的为运行时线程所处的状态。当 start 方法调用时,线程首先进入可运行状态。在线程运行之后或者从阻塞、等待或睡眠状态回来后,也返回到可运行状态。
3.运行:线程调度程序从可运行池中选择一个线程作为运行状态。这也是线程进入运行状态的唯一一种方式。
4.等待/阻塞/睡眠:这是线程有资格运行时它所处的状态。实际上这个状态组合为一种,其共同点是:线程仍然是活动,但是不具备运行条件,在满足了一定条件之后会回到可运行状态。
5.销毁:当现场的 run 方法完成时,该线程就会被销毁,也许线程对象还是活的,但是它已经不是一个单独执行的线程。如果再次调用 start 方法,会抛出 IllegalThreadStateException 异常。

组织线程运行

对于线程的阻止,考虑一下三个方面(不考虑 I/O阻塞)

  • 睡眠
  • 等待
  • 因为需要一个对象的锁定而被阻塞

睡眠

Thread.sleep()静态方法强制当前正在执行的线程休眠(暂停执行),以“减慢线程”。当线程睡眠时,它入睡在某个地方,在苏醒前不会返回到可运行状态。当睡眠时间到期,则返回到可运行状态。

线程睡眠的原因:线程执行太快,或者需要强制进入下一轮,因为 Java 规范不保证合理的轮换。

睡眠的实现:调用静态方法

try{
    Thread.sleep(100);
}catch(Exception e){
}

睡眠的位置,为了让其他线程有机会执行,可以将 Thread.sleep()的调用放线程 run方法之内,这样才能保证该线程执行过程中会睡眠。

注意:

  • 线程睡眠是帮助所有线程活得运行机会的最好方法。
  • 线程睡眠到期自动苏醒,并返回到可运行状态,不是运行状态。
  • sleep 是静态方法,只能控制当前正在运行的线程。

线程的优先级和线程让步 yield()

线程的让步是通过 Thread.yield()来实现的。yield()方法的作用是:暂停当前正在执行的线程对象,并执行其他线程。

要理解 yield,必须了解线程的优先级的概念。线程总是存在优先级,优先级范围在1~10之间。JVM 线程调度程序是基于优先级的抢先调度机制。在大多数情况下,下一个选择为运行的线程优先级将大于或等于线程池中任何线程的优先级。

注意:当设计多线程应用程序的时候,一定不要依赖于线程的优先级。因为线程调度优先级操作是没有保障的,只能把线程优先级作用为一种提高程序效率的方法,但是要保证程序不依赖这种操作。

当线程池中线程都具有相同的优先级,调度的 JVM 实现自由选择它喜欢的线程。这时候调度程序的操作有两种可能:一是选择一个线程运行,直到它阻塞或者运行完成为止。二是时间分片,为池内的每个线程提供均等的运行机会。

设置线程的优先级:线程默认的优先级是创建她的执行线程的优先级。可以通过 setPriority(int newPropority)更改线程的优先级。例如:

Thread t = new Thread();
t.setPriority(8);
t.start();

线程优先级为1~10直接的正正式,JVM 从不会改变一个线程的优先级。然而1~10直接的值是没有保证的。一些 JVM 可能不能识别10个不同的值,而将这些优先级进行每两个或多个合并,变成少于10个的优先级,则两个或多个优先级的线程可能被映射成为一个优先级。

线程默认优先级是5,Thread 类中有三个常量,定义线程优先级范围:

/**
 * The minimum priority that a thread can have.
 */
public final static int MIN_PRIORITY = 1;
/**
 * The default priority that is assigned to a thread.
 */
public final static int NORM_PRIORITY = 5;

/**
 * The maximum priority that a thread can have.
 */
public final static int MAX_PRIORITY = 10;

Thread.yield() 方法

Thread.yield() 方法作用是:暂停当前正在实行的线程,并将其变成可运行线程,再从可运行线程池里面随机选择一个线程作为运行线程。

join 方法

Thread的非静态方法join()让一个线程B“加入”到另外一个线程A的尾部。在A执行完毕之前,B不能工作

如以下代码,在线程 t 执行结束之前,int i 是不会被执行到的。

Thread t = new MyThread();
t.start();
t.join();
int i = 0;

小结

到目前为止,介绍了线程离开运行状态的3种方法:
1.调用 Thread.sleep()方法
2.调用 Thread.yield()方法
3.调用 join 方法。

除了以上三种外,还有下面几种特殊情况可能使线程离开运行状态:
1.线程 run 方法结束
2.调用 Object 的 wait 方法。
3.线程不能在对象上获得锁定,它正试图运行该对象的方法代码
3.线程调度程序可以决定将当前运行状态移动到可运行状态,以便让另一个线程获得运行机会,不需要任何理由。

Java 线程:线程的同步与锁

同步问题提出

线程的同步是为了防止多个线程访问一个数据对象时,对数据早餐的破坏。
例如:两个线程 A、B 都操作同一个对象,并修改对象上的数据。

懒得贴代码,我直接贴图了。

我们的期望值 i 是不能出现小于0的情况的,但是由于多线程的操作,出现了-1.

同步和锁定

锁的原理

Java 中每个对象都有一个内置锁

当程序运行到非静态的 synchronized 同步方法上时,自动获得与之正在执行代码类的当前实力(this 实力)有关的锁。获得一个对象的锁也称为获取锁、锁定对象、在对象上锁定或在对象上同步。

当程序运行到 synchronized 同步方法或者代码块时候,才对该对象锁起作用。

一个对象只有一个锁。所以,如果一个线程获得该锁,就没有其他线程可以获得锁,知道第一个线程释放锁。这意味着其他任何线程都不能进入到该对象上的 synchronized 方法或代码块,直到该锁被释放。

锁释放是指持锁的线程退出了 synchronized 同步代码块。

关于锁和同步,有以下几个点需要注意:
1.只能同步方法,不能同步变量和类;
2.每个对象只有一个锁;当提到同步时,应该清楚在什么上同步?也就是说,在哪个对象上同步。
3.不必同步的类中所有的方法,类可以同时拥有同步和非同步的方法。
4.如果两个线程要执行同一个类中的 synchronized 方法,并且两个线程使用相同的实例来调用方法,那么一次只能有一个线程能够获取到锁并执行方法,另外一个线程需要等待,直到锁被释放。
5.如果线程拥有同步和非同步方法,则非同步方法没有访问限制。
6.线程睡眠时,它所持有的任何锁都不会被释放
7.线程可以获得多个锁。但是要注意避免死锁
8.同步损害并发性,应该尽可能缩小同步范围。同步不必同步整个方法,可以只同步方法中的几句代码。
9.在方法上加 synchronized 和在方法里面加 synchronized 代码块把所有的直接语句都包裹的效果是一样的。

静态方法同步

由于静态方法随着类的加载而加载的,先于对象而存在,所以静态方法同步用的锁是这个类的 class。

public static synchronized int setName(String name){
      Xxx.name = name;
}
等价于
public static int setName(String name){
      synchronized(Xxx.class){
            Xxx.name = name;
      }
}

如果线程不能获得锁会怎样

如果线程视图进入同步方法,而锁已经被占用,则该线程在该对象上被阻塞。实质上,线程进入该对象的一种池中,必须在那里等待,知道锁被释放,该线程再次变为可运行或运行为止。

当考虑阻塞时,一定要注意哪个对象正被用于锁定:
1.调用同一个对象中非静态同步方法的线程将彼此阻塞。如果是不同对象,则每个线程有自己的对象的锁,线程间彼此互不干扰。
2.调用同一类中的静态同步方法的线程将彼此阻塞,它们都是锁定在相同的 Class 对象上。
3.静态同步方法和非静态同步方法将永远不会彼此阻塞,因为静态方法所在 Class 对象上,非静态方法锁定在该类的对象上。
4.对于同步代码块,要看清楚什么对象已经用于锁定。在同一个对象进行同步的线程将彼此阻塞,在不同对象上锁定的线程将永远不会彼此阻塞。

何时需要同步

在多个线程同时访问互斥(可交互)数据时,应该同步以保护数据,确保两个线程不会同时修改它。

对于非静态字段可更改的数据,通常使用非静态方法访问。
对于静态字段可更改的数据,通常使用静态方法访问。

线程安全类

当一个类以及很好的同步以保护它的数据时,这个类就称为“线程安全的”。

即使是线程安全类,也要特别小心,因为操作的线程间仍然不一定安全。

举个例子,我们都知道 ArrayLis 不是线程安全的类,但是 Collections.synchronizedList()这个工具类装饰一下,就变成线程安全的了,如下:

ArrayList<String> arrayList = new ArrayList<>();
List<String> list = Collections.synchronizedList(arrayList);

此时 list 是一个线程安全类了对吧,现在多个线程执行以下语句

if (list.size()!=0){
    list.remove(0);
}

是不是同样会出现问题。哈哈哈哈~此题答案不唯一,看到这里的童鞋不会处理这个问题的话,回头再把这篇文章重新看一遍吧。

线程死锁

死锁对于 Java 程序来说,是很复杂的,也很难排查。正常情况下,我们故意写出死锁代码,也很难出现一次问题。

public class DeadlockRisk {
    private static class Resource {
        publicint value;
    } 

    private Resource resourceA =new Resource();
    private Resource resourceB =new Resource();

    publicint read() {
        synchronized (resourceA) {
               Thread.sleep(50);
            synchronized (resourceB) {
                return resourceB.value + resourceA.value;
            } 
        } 
    } 

    publicvoid write(int a,int b) { 
        synchronized (resourceB) {
                Thread.sleep(50);
            synchronized (resourceA) {
                resourceA.value = a; 
                resourceB.value = b; 
            } 
        } 
    } 
}

为了让死锁必现,我让线程睡眠了50ms,当两个不同的线程同时 分别访问 read 方法和 write 方法时,程序就 GG 了。

线程同步小结

1.线程同步的目的是为了保护多个线程访问一个资源时对资源的破坏。
2.线程同步方法是通过锁来实现,每个对象都有且仅有一个锁,这个锁与一个特定的对象关联,线程一旦获取了对象锁,其他方法该对象的线程就无法再访问对象的其他同步方法。
3.对于静态同步方法,锁是针对这个类的,锁对象是该类的 Class 对象。静态和非静态方法的锁互不干扰。一个线程活得锁,当在一个同步方法中访问另外对象上的同步方法时,或获取这两个锁。
4.对于同步,要时刻清醒在哪个对象上同步,这是关键。
5.编写线程安全的类,需要时刻注意对多个线程竞争访问资源的逻辑和安全作出正确的判断,对“原子”操作做出分析,并保证院子操作期间别的线程无法访问竞争资源。
6.当多个线程等待一个对象锁时,没有获取到锁的线程将发送阻塞。
7.死锁是线程间相互等待锁造成的,在实际中发送的概率非常小。真写个死锁,不一定好使,但是一旦发生死锁,程序就 GG 了。