轩辕李的博客 轩辕李的博客
首页
  • Java
  • Spring
  • 其他语言
  • 工具
  • HTML&CSS
  • JavaScript
  • 分布式
  • 代码质量管理
  • 基础
  • 操作系统
  • 计算机网络
  • 编程范式
  • 安全
  • 中间件
  • 心得
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

轩辕李

勇猛精进,星辰大海
首页
  • Java
  • Spring
  • 其他语言
  • 工具
  • HTML&CSS
  • JavaScript
  • 分布式
  • 代码质量管理
  • 基础
  • 操作系统
  • 计算机网络
  • 编程范式
  • 安全
  • 中间件
  • 心得
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Java

    • 核心

    • 并发

      • Java并发-线程基础与synchronized关键字
        • 一、线程基础知识
        • 二、线程基本操作
          • 1、新建并启动
          • 2、终止
          • 3、中断
          • 4、等待和通知
          • 5、join和yield
        • 三、synchronized关键字
          • 变化的锁对象的风险
          • 可重入
      • Java并发-重入锁ReetrantLock的使用
      • Java并发-信号量Semaphore
      • Java并发-读写锁ReadWriteLock
      • Java并发-倒计时器CountDownLatch
      • Java并发-栅栏CyclicBarrier
      • Java并发-LockSupport线程阻塞工具类
      • Java并发-线程池ThreadPoolExecutor
      • Java并发-阻塞队列BlockingQueue
      • Java并发-以空间换时间之ThreadLocal
      • Java并发-无锁策略CAS与atomic包
      • Java并发-JDK并发容器
      • Java并发-异步调用结果之Future和CompletableFuture
      • Java并发-Fork Join框架
      • Java并发-调试与诊断
    • 经验

    • JVM

    • 企业应用

  • Spring

  • 其他语言

  • 工具

  • 后端
  • Java
  • 并发
轩辕李
2018-05-31
目录

Java并发-线程基础与synchronized关键字

程序开发中并发的场景还是比较常见的,特别是当下分布式环环境开发大行其道的情况下,从前端处理,到服务调用、缓存处理、数据库处理、文件处理、消息处理等等,无不需要并发的知识。
从今天开始,我要写一个关于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、终止

首先我们要明确:JVM会在所有线程执行完任务之后退出,不论是主线程还是子线程。除非子线程是守护线程,也就是thread.setDaemon(true),那么守护线程会随着主线程一起停止。
也就是说如果启动了一个子线程,而子线程是一个长时间任务,不手动停止它的话,JVM不会自动退出的。

原来线程中是有一个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()方法,A才继续执行。这时,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) {
		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() {
		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 final 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的实现原理 (opens new window)

# 变化的锁对象的风险

使用synchronized加锁的过程中,需要注意锁对象不能是变化的,比如synchronized (new Object())这样的写法是不合理的。

另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。

Integer 和 String 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。

# 可重入

关于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实例对象的锁已经被占用,那么这时候会不会发生死锁呢,有可能编程了永久等待的情况。
为了解决上面的问题,引入可重入的概念:内部锁是可重进入的,线程在试图获得它自己占有的锁时,请求会成功。
重进入是基于每线程,而不是每调用的。

编辑 (opens new window)
#synchronized
上次更新: 2023/08/14
聊聊classpath及其资源获取
Java并发-重入锁ReetrantLock的使用

← 聊聊classpath及其资源获取 Java并发-重入锁ReetrantLock的使用→

最近更新
01
Spring Boot版本新特性
09-15
02
Spring框架版本新特性
09-01
03
Spring Boot开发初体验
08-15
更多文章>
Theme by Vdoing | Copyright © 2018-2025 京ICP备2021021832号-2 | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式