当前位置:   article > 正文

Java并发-线程与进程、线程安全、锁、阻塞队列详解,看这一篇就够了_java 进程锁

java 进程锁

进程和线程

进程是系统分配资源和调度的基本单位,也就是说进程可以单独运行一段程序。线程是CPU调度和分派的最小基本单位。

线程的属性

1.线程ID

每一个线程都有独一无二的id,不可重复。可以通过Thread.getId()来获取线程的id。

2.线程名称

我们可以在创造现成的时候,给线程起一个名字。这个名字一般用于调试。可以通过Thread.getName()来获取线程名称,通过Thread.setName(String name)来设置线程名称。

3.线程状态

线程共有5个状态:新建状态、就绪状态、运行状态、阻塞状态、终止状态。详情可以看下一章节,有5个线程状态的详解。

可以通过Thread.getState()来查看线程的状态。

4.线程优先级

在Java语言中,每一个线程都有一个优先级,在创建线程的时候就已经设置好了,我们可以自己定义线程的优先级。

默认情况下, 一个线程会继承构造它的那个线程的优先级,即那个线程创建的这个线程,那么这两个线程的优先级相同。

Java中优先级设置了10个级别,MIN_PRIORITY=1,MAX_PRIORITY = 10,NORM_PRIORITY=5

通过调用Thread.setPriority(int priority)方法设置线程优先级,通过Thread.getPriority()方法获取线程优先级。

Java中线程调度器使用优先级调度算法,优先级越大的越先调用。但是线程的执行高度依靠操作系统来进行调度,实际执行时会将Java线程的优先级映射到操作系统上,但是操作系统的优先级并不一定与Java中一一对应,此时会出现优先级不同的线程,被当作优先级相同的情况处理。。

例如Windows系统中仅仅只有7个优先级级别,故我们设置的Java优先级在映射到操作系统中是有可能会产生冲突的。

另外,Oracle为Linux提供的Java虚拟机中,会完全忽略线程优先级——所有线程都有相同的优先级。

综上,目前现在很少使用线程优先级了。

5.是否存活

当我们的线程调用start的时候就存活,直至run方法执行完毕,线程自然死亡。或者是一个未捕获的异常终止了run方法而使线程猝死。

可以使用Thread.isAlive()查看线程是否存活,返回值为true为线程存活,反之则不存活。

6.是否中断

线程中断就是字面意思,即中断一个正在运行的线程。但是首先我们要明确一点,没有任何语言要求终端的线程必须终止,中断线程只是给系统一个提醒,是否中断要看操作系统自身的调度,以及代码的实现。

举个简单的例子,我们只能作为用户向操作系统发起请求,请求让该系统中断线程,但是我们并没有资格直接去中断线程,最终决策线程是否中断的权利是在操作系统手中的。

可以使用Thread.isInterrupted()查看线程是否被中断,返回值为true为线程终端,反之则未中断。

值得注意的是:不要把interrupt()方法,interrupted()方法与isInterrupted()搞混。

  • interrupt()方法是普通方法,作用是将此时的标志位设置为true,也就是发送中断请求,改变线程的状态。

  • interrupted()方法是静态方法,作用是判断当前线程的中断标志位是否设置,该方法有一个副作用,调用后清除标志位,即将线程的中断状态设置为false

  • isInterrupted()作用则是测试是否被中断,也就是返回标志位的值,该方法不会改变线程的状态,不清除标志位。

7.是否为守护线程

守护线程又被称为后台线程,即线程在后台运行。前台线程会阻止进程退出,如果main结束了但是前台线程还没执行完毕,进程不会退出 ,后台线程不阻止进程退出,如果main等的前台线程执行完了,但是后台线程没有执行完,则进程让然会结束。

守护线程不参与线程资源争夺,是独立于其他线程之外的,它会同线程执行一并执行,线程结束它也会结束,常见的日志和GC都属于守护线程。

可以使用Thread.setDaemon()设置成守护线程,设置操作在start前操作,线程启动之后就没法改.

JVM会在一个进程的所有非后台线程结束后,才会结束运行。

JVM中默认有两个线程,main线程和gc线程,gc线程为守护线程,在后台运行。

如果一个进程中只剩下守护线程,那程序的运行也就是没有必要了,守护线程一般是为其他线程服务的。

线程的5种状态

  1. 新建状态(New)

    当使用new操作符创建一个新线程时,如new Thread®,这个线程还没有开始运行,这个时候线程的状态就是新建状态(new)。当一个线程处于新建状态时,程序还没有开始运行线程中的代码,在正式线程的运行之前还有一些基础工作要做。

  2. 就绪状态(Runnable)

    新建线程对象后,调用该线程的start()方法就可以启动线程。当线程启动时,线程就进入就绪状态(runnable),即所有的准备工作都已经准备好了,就差CPU分配时间片。
    由于还没有分配CPU,线程将进入线程队列排队,等待CPU服务,这表明它已经具备了运行条件。当系统挑选一个等待执行的Thread对象后,它就会从等待状态进入执行状态。系统挑选的动作称之为“CPU调度”。一旦获得CPU,线程就进入运行状态并自动调用自己的run方法。

  3. 运行状态(Running)

    该状态是指线程获得了CPU的时间片,线程调用了自己的run方法,线程正在执行中。

  4. 阻塞状态(Blocked)

    线程在运行途中,一个正在执行的线程在某些特殊情况下,如被人为挂起或需要执行耗时的输入输出操作时,被剥夺了CPU的使用,随即从运行状态(Running)转变为阻塞状态(Blocked)。

    在可执行状态下,如果调用sleep()、suspend()、wait()等方法,线程都将进入阻塞状态。阻塞时,线程不能进入排队队列,只能当引起阻塞的原因被消除后,线程转入就绪状态,重新到就绪队列中排队等待,这时被CPU调度选中后会从原来停止的位置开始继续执行。

  5. 终止状态(Terminated)

    线程调用了stop()、destory()或者run()执行完毕以后,线程随即处于终止状态,又称为死亡状态,处于终止状态的线程不再具有继续运行的能力。

线程状态
线程状态

线程同步

竞态条件

在大多数程序中,可能存在两个或者两个以上的线程需要共享同一对数据,如操作同一个对象,并且每个线程调用了修改该对象的方法。

此时这两个线程的操作会互相覆盖,覆盖的先后顺序取决于线程访问对象的先后顺序,可能会导致对象被破坏。这种情况称为竞态条件。

案例详解

举个简单的例子,我们在进行银行转账操作的时候,一般需要如下操作:

  1. 判断转出账户余额是否充足;
  2. 从转出账户中扣除对应金额;
  3. 向转入账户内添加对应金额;

操作完这三步以后,才能说正确的完成了转账操作。这里我们以操作3进行详细解读:

操作3的具体步骤为:

  1. 读取账户余额;
  2. 计算增加后的账户余额;
  3. 将余额写入账户内。

但是在多线程的情况下,一个线程可能在步骤1和步骤2完毕以后,线程失去CPU进入就绪状态,CPU切换到另外一个线程,另外一个线程执行相同操作,那么在该线程的操作中,仅仅只运行到了步骤1,随即线程2被剥夺运行权,此时切换到线程1,继续执行步骤3,操作完毕以后,线程2开始运行,此时计算的账户金额,是以第一次运行时读取的账户余额为基础计算的,那么重新写回账户的金额就会出错,即将线程1的操作进行覆盖,钱款会存在丢失的情况。

问题就在于一个操作的过程并非原子性,那么解决该问题的方案就是给操作加锁,即防治并发访问代码块。

锁对象

锁的功能
  • 锁用来保护代码片段,一次只能有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程。
  • 一个锁可以有一个或者多个相关联的条件对象。
  • 每个条件对象管理那些已经进入被保护代码段但还不能运行的线程。

目前防止并发访问代码块有两种实现方法。Java语言提供了一个synchronized关键字来达到这个目的,Java5以后还引入了一个ReentrantLock类来实现。

ReentranLock类

这里我们主要讲解ReentrantLock类的使用,在后面讲解synchronized关键字

ReentrantLock类保护代码块不被并发访问的基本结构如下所示:

myLock.lock();	// 获得锁,即锁定操作,防止其他线程闯入
try{
  	// 需要实现的关键部分
}finally{
  // 确保在关键部分执行时,只有一个线程能够进入
  mylock.unlock();	// 释放锁,即解锁操作,开放给其他线程
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这个结构确保任何时刻只有一个线程进入临界区,一旦一个线程锁定了对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会暂停,直到第一个线程释放这个锁对象。

这里需要重点强调的是必须将解锁操作放到finally子句中进行,否则一旦try语句内抛出一个异常,其他线程将永远阻塞,锁无论在什么情况下,必须释放,才是安全的。

重入锁(reentrantLock):即表示可重新反复进入的锁,但仅限于当前线程,因为一个线程可以反复获得已拥有的锁。

锁拥有一个计数器(hold count)来跟踪对lock方法的嵌套调用,类似于==()、[]、{}==的使用,是成双成对存在的。

线程每调用一次lock方法获得锁后,都必须对应调用一次unlock方法类释放锁。由于这个特性,被一个锁保护的代码可以调用另外一个使用相同锁的方法。

公平锁:是一个倾向于等待时间最长的线程,不过这种锁会严重影响性能。一般情况下,不要求锁是公平的。

即便是使用公平锁,也无法保证线程调度器是公平的。

条件对象

通常情况下,线程进入临界区后需要进行判断,只有满足去某个条件以后才能够执行。可以使用条件对象来管理那些已经获得锁,但是并不能进行有效工作的线程。

举个简单的例子,我们在进行银行转账操作时,需要判断余额是否充足,如果不充足,则不能进行转账操作,需要进行充值。充值以后,余额充足才能够进行转账操作,但是在判断之前我们就获得了锁,导致其他线程无法进入操作,此时该线程将永无止境的等待下去。

为了解决这种问题,我们需要引入条件对象。

可以使用newCondition方法获取一个条件对象,习惯上会给每个条件对象一个合适的名字来反映他所表示的条件。

从整体上来看Object的wait和notify/notify是与对象监视器配合完成线程间的等待/通知机制,而Condition与Lock配合完成等待通知机制,前者是java底层级别的,后者是语言级别的,具有更高的可控制性和扩展性。两者除了在使用方式上不同外,在功能特性上还是有很多的不同:

  1. Condition能够支持不响应中断,而通过使用Object方式不支持
  2. Condition能够支持多个等待队列(new 多个Condition对象),而Object方式只能支持一个
  3. Condition能够支持超时时间的设置,而Object不支持

Condition由ReentrantLock对象创建,并且可以同时创建多个,Condition接口在使用前必须先调用ReentrantLock的lock()方法获得锁,之后调用Condition接口的await()将释放锁,并且在该Condition上等待,直到有其他线程调用Condition的signal()方法唤醒线程,使用方式和wait()、notify()类似。

Condition类中的常用方法:

  • void await() throw InterruptedException:当前线程进入等待状态,如果其他线程调用condition的signal或者signalAll方法并且当前线程获取Lock从await方法返回,如果在等待状态中被中断会抛出被中断异常;
  • long awaitNanos(long nanosTimeout):当前线程进入等待状态直到被通知,中断或者超时
  • boolean await(long time, TimeUnit unit) throws InterruptedException:同第二种,支持自定义时间单位,false:表示方法超时之后自动返回的,true:表示等待还未超时时,await方法就返回了(超时之前,被其他线程唤醒了)
  • boolean awaitUntil(Date deadline) throws InterruptedException:当前线程进入等待状态直到被通知,中断或者到了某个时间
  • void awaitUninterruptibly();:当前线程进入等待状态,不会响应线程中断操作,只能通过唤醒的方式让线程继续
  • void signal():唤醒一个等待在condition上的线程,将该线程从等待队列中转移到同步队列中,如果在同步队列中能够竞争到Lock则可以从等待方法中返回。
  • void signalAll():与signal()的区别在于能够唤醒所有等待在condition上的线程

通常await()的调用应当放在条件循环中调用:

while(!(OK to proceed)){
  		condition.await();
}
  • 1
  • 2
  • 3

当一个线程调用await()方法以后,它没有办法重新自行激活,只能被其他线程使用signal()或者signalAll()激活,如果不存在其他线程调用这两个激活方法,则将陷入死锁。

从经验上讲,只要一个对象的状态有变化,而且有可能对其他线程有利,即就是改变以后符合其他线程的执行要求,则就应当调用signalAll()。

signalAll()并不会立即激活一个等待的线程,只是接触等待线程的阻塞,使这些线程可以在当前线程释放锁后竞争访问对象。

Signal()只是随机选择等待集中的一个线程,并且解除这个线程的阻塞状态。这比接所有线程的阻塞更高效,但是也更危险。如果发生选择的线程已经被解除阻塞,仍不能运行,其会再次阻塞。此时如果没有其他线程调用signal(),那么系统就会陷入死锁。

synchronized关键字

synchronized关键字会自动提供一个锁,以及相关的条件,对于大多数的需要显示锁的情况,这种机制功能是非常强大的。

通常情况下,我们不需要太精确且复杂的锁控制机制,Java的每个对象内部都维护了一个锁,直接使用synchronized关键字声明方法,则对象的锁将保护整个方法。

当synchronized修饰静态方法时,则会将获得类对象的锁,即对应类.class对象的锁,访问该类时需要使用锁。

同步块

使用synchronized修饰的代码段,在该代码段内会获得对象的锁,简单的理解为给代码段加锁,使得代码段的执行具有了原子性。

public class Bank{
  private double[] accounts;
  private Object lock = new Object();
  
  public void transfer(int from,int to,int cmount){
    synchronized (lock){
      accounts[from] -= amount;
      accounts[to] += amount;
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
监视器
  • 监视器是只包含私有字段的类;
  • 监视器类的每个对象有一个关联的锁;
  • 所有方法由这个锁锁定;
  • 锁可以有任意多个相关联的条件。
volatile关键字

volatile关键字修饰字段,则任何修改该字段的方法都默认被加上锁。

final关键字

final关键字修饰的变量,一经初始化,则无法修改。

如果修饰的字段是基本数据类型,则不会出现任何问题;但如果修饰的是引用类型的字段,尽管引用不会改变,但是引用指向的对象是会改变的,如果多线程对该对象实体进行操作,仍然需要同步。

原子性

java.util.curcurrent.atomic包下的很多类使用了很高效的机器级别指令来保证操作的原子性。

例如:AtomicInteger类提供的incrementAndGet()和decrementAndget()方法实现了原子性的自增和自减。

// 定义一个数
int num = 1;
// 使其自增
num ++; // 这个操作并不是原子性的,再多线程的操作时会出现问题。

System.out.println(num) 	// 此时num == 2
// 使用AtomicInteger
public static AtomicInteger atomic = new AtomicInteger();

num = 1; 	// 重新对num赋值
num.incrementAndGet();		// 调用自增方法

System.out.println(num) 	// 此时num == 2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

该包下的类还有很多方法提供,具体的使用可以查阅文档。

死锁

两个线程互相持有对方所需要的锁,但只有在获得锁,执行完以后才能释放锁,此时进入僵局,故为死锁。

线程安全的集合

阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。

生产者线程向队列插入元素,消费者线程向队列输出元素。使用队列时可以安全的从一个线程向另一个线程传递数据。

当试图向队列添加元素而队列已满,或者是从队列移出元素而队列为空时,阻塞队列将导致线程阻塞。

阻塞队列有以下方法:
  1. put(E e):将元素添加到队列的尾部,如果队列已满,则阻塞等待直到队列有空闲空间。

  2. take():从队列头部移除并返回元素,如果队列为空,则阻塞等待直到队列有新元素。

  3. offer(E e):将元素添加到队列的尾部,如果队列已满,则立即返回false。

  4. poll():从队列头部移除并返回元素,如果队列为空,则立即返回null。

  5. offer(E e, long timeout, TimeUnit unit):将元素添加到队列的尾部,如果队列已满,则阻塞等待指定的时间,超时后返回false。

  6. poll(long timeout, TimeUnit unit):从队列头部移除并返回元素,如果队列为空,则阻塞等待指定的时间,超时后返回null。

  7. remainingCapacity():返回队列中剩余的可用空间。

  8. size():返回队列中的元素个数。

阻塞队列在Java中大致有以下几种实现:
  • java.util.concurrent.ArraysBlockingQueue:构造一个有指定容量和公平性设置的阻塞队列。队列实现为一个循环数组。

  • java.util.concurrent.LinkedBlockingQueue:构造一个无上限的阻塞队列或者双向队列,实现为一个链表。

  • java.util.concurrent.LinkedBlockingDeque:根据容量构建一个有限的阻塞队列或者双向队列,实现为一个链表。

  • java.util.concurrent.DelayQueue:构造一个包含Delay元素的无上限阻塞队列,只有那些延迟已经到期的元素可以从队列中移除。

高效的实现

  • java.util.concurrent.ConcurrentHashMap:哈希表映射
  • java.util.concurrent.ConcurrentSkipList:列表
  • java.util.concurrent.ConcurrentSkipListSet:set集合
  • java.util.concurrent.ConcurrentLinkedQuene:队列

这些类的size()方法不一定在常量时间内完成操作。

同步包装

任何集合类使用同步包装器变成线程安全的。

List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K,V> synchHashMap = Collections.synchronizedMap(new HashMap<K,V>());
  • 1
  • 2

示例将ArrayList和HashMap都变成了线程安全的。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/560647
推荐阅读
相关标签
  

闽ICP备14008679号