java中怎么判断一段代码时线程安全还是非线程安全_Java并发学习第五弹:线程异常处理和线程安全...

一、线程的属性都有什么?

0f8a44fa9e9c7e11dbcccf51079e97d8.png

(一)线程ID:

线程ID是由1开始自增的:

e88b4e0421ade10dcc5cf05a83b97528.png

那么主线程为什么是1呢,各种集合不是都是从0开始计数的吗?而且子线程怎么一下子从1蹦到了12呢?具体需要看看源码了

0cd7091d6b507f5aeb4056a1854f9f84.png

由Thread代码可知,线程的ID是通过threadSeqNumber来进行自增的。

主线程是1的问题解决了,那么子线程为什么是12呢?可以打断点来看一下

221ead8fe658deff7d4c712b4e2708d3.png

从上图中可以发现,其实运行到断点时,系统中除了main已经有多个线程在执行了。

其中的signalDispatcher和Attach Listener都是用于jvm进程间通信的。

Reference Handler是和GC引用相关的

finalizer 是为了执行finalizer方法的

所以实际上JVM是自己创建了很多线程的。

(二)线程的名字:

我们可以手动给线程命名,当我们没有为线程命名时,JVM会给每个线程创建一个默认的名字。

我们继续看源码:

aa8ec2d0e51075368be27826b7829d32.png

可以看出,当我们没有传入任何名字时,这个名字是一个写死的Thread-后面拼接了个方法的返回值,我们进入这个方法:

53d280fda568f044fa9ed81ad389ace0.png

可以看到这是一个synchronized修饰的方法,其中的threadInitNumber是自增的,也就是说无论有多少线程进入这个方法都不会获得重复的线程名字。

那么当我们自己修改线程名字时是如何完成的呢?

首先先看当我们初始化线程时传入一个名字:

0c829635628edd9e69fb9abf27b99614.png

最后就可以进入一个这样的方法:

ed237fdfc025af7b629725e40fa6c1d0.png

这里对名字进行了判断并做了赋值。

那么如果我们不在初始化的时候传入name呢?

我们可以调用thread.setName()方法并传入一个名字:

f86bbc6c4bff2d68382cbd007159dd19.png

这段代码首先做了安全检查,对名字进行了判空,然后对Thread内部的name进行了赋值,但是下面还有一个方法setNativeName,那么这个方法有什么作用呢?

00e9bf74ce2c853c451ccc87ffc3e9d9.png

可以看到这是一个native方法,它实际上是在C或者C++层次对线程的名字进行了修改,由于在执行setNativeName方法前对threadStatus进行了判断:

dd0e4871092c7a6bf4bc9c87d5cd4a95.png

当线程状态为0时,这个线程是没有执行的。所以当线程一旦执行起来,就无法在C或者C++层次对线程的名字进行修改了,只可以基于java代码层面对线程名字精选更改。

(三)守护线程:

守护线程是给用户线程提供服务的。

那么守护线程和用户线程是如何区分的呢?

用户线程在执行完毕之前JVM是不会关闭的,JVM会等待所有的用户线程执行完毕。但是如果系统中只剩下守护线程运行而用户线程全部执行完毕,JVM会和守护线程一起停止下来。

平时大家所说的,jvm会在线程执行完毕时停下来(或者人为的操作他停下来)这里所说的线程执行完毕只是指用户线程执行完毕,而和守护线程无关。

在这里守护线程有三大特性:

1.线程类型默认是继承自父线程的,那么当用户线程创建线程时,创建出的子线程都是用户线程,而守护线程所创建的线程都是守护线程,换句话说通常情况下用户是无法直接通过用户线程去创建守护线程的(这里指通常情况下,非要创建守护线程时可以通过thread.setDaemon(true)来将一个没有执行的用户线程设置为守护线程。)

2.通常而言,守护线程都是由jvm自动启动的,在jvm启动时只有main是非守护线程,其他的都是守护线程;

3.守护线程时不影响JVM退出的,当用户线程都停止了,JVM哪怕发现有4-5个守护线程还在执行,JVM还是会正常停止。

守护线程和用户线程有什么区别吗?

守护线程其实实质上还是线程,整体来说用户线程和守护线程没什么区别

实质上的区别在于JVM退出时线程是否需要运行完毕。

我们是否需要给线程设置为守护线程?

其实我们是不应该将用户线程设置为守护线程的,因为如果这个线程去访问固有资源,如文件、数据库时,系统中已经没有用户线程执行了,JVM会直接关闭导致线程工作中断,影响到数据的一致性。JVM现有的自动创建的守护线程其实已经足够完成相关工作了,并不需要人为的去设置。

(四)线程优先级:

java中的线程优先级是有10个级别的,我们创建出的线程默认优先级为5。

子线程的优先级会继承父线程的优先级。

在Thread源码中有以下三行代码,代表了最低优先级、默认优先级和最高优先级。

1bc3d271dc2ae675565bbe62516d9962.png

注意:程序设计其实不应该依赖于优先级。为什么呢?

主要有三个原因:1.不同的操作系统对于优先级的理解是不同的。而java的优先级会被映射到操作系统中,线程的调度也是依赖于操作系统的,不同的操作系统有可能优先级级别并不足10个,比如window的优先级只有7个级别,那么java会对程序的优先级做一个映射,可能会导致无法一一对应,如果操作系统不分优先级(linux)还比较好处理,但是对于其他的有很多优先级的操作系统,或者不足10个优先级的系统,这样会使得java在不同的操作系统中运行时无法达到很好的跨平台效果。2.部分操作系统对于优先级比较低的但是一直在等待的线程可能会调度它进行插队,这样一来优先级就被忽略了,导致代码不可靠。3.优先级极低的程序还有可能会被饿死(即一直获取不到cpu资源),虽然我们设定的优先级比较低,但是并不代表不想让他运行啊。

接下来对表格进行补充

6a310e27e2e7a8226cc924e1e752b7d3.png

二、线程异常处理

(一)Java异常体系

Java异常都是继承了Throwable的

其中Error是不需要在代码中进行处理(即使发现了也不能在运行期间逆转),指的是系统运行期间系统内部错误,或者资源耗尽,或者死锁这些异常java只能告诉我们结果,并不能通过代码层面直接catch住并进行处理,这个Error一旦出现系统将面临崩溃。

而Exception代表的是编码出现问题、环境出现异常、用户输入有问题

Exception被划分为两类:

RuntimeRxception(非受检查异常也叫运行期异常):

这个是代码导致的问题,这些都是程序自己本身的问题,只要在代码中进行处理即可以避免。

这种问题编译器是无法检测到的。比如引用空对象的属性或者方法、数组访问越界了、类型转换错误、算数时候出错(分母为0)等

这些异常会由JVM虚拟机自动拦截自动捕获,需要在逻辑上改正。

IOException(受检查异常):

这类异常是编译器可以检查出来的,在可能发现该异常的时候,java编译器会提醒我们,让我们提前进行处理。

(二)如何处理全局异常?为什么要处理呢?

做项目时肯定会出现各种无法预料的异常,这样我们对全局异常进行处理则相当于做了一个兜底的工作。如果我们不做这样的处理,异常直接打印到前端信息,没有做拦截就返回给用户,这样可以让浏览者看到堆栈信息,影响程序安全性。

(三)使用合适的方法处理线程异常的重要性:

1、主线程可以轻松发现异常,但是子线程却不行(当单线程情况下如果碰到了未捕获异常会被抛出而程序终止,代码编写者很容易,而子线程在抛出异常后程序并不会中止,用户可能无法敏感的发现日志(控制台)中的异常内容)

38aa80a1168fd2c2ec623889f02a3983.png

2、子线程的异常无法用传统的方法捕获(try/catch)

3e9496af91b0a5c610af10cd825ba05d.png

使用catch后,catch没有捕获住异常,和未使用try/catch之前是一样的。说明try/catch无法捕获子线程异常,只能捕捉本线程异常。

3、不能直接捕获子线程的异常,子线程本身会停止运行,但是父线程不会影响,这会导致程序运行过程中的一些可能的必要工作没有完成,而使用UncaughtExceptionHandler可以帮助我们处理相关异常,避免不必要的损失,提高程序的健壮性。

(四)对每个子线程进行try/catch:

3b39bf9d097b2028468f02345900ebbc.png

用上图中的方法可以对子线程中的异常进行捕获,将其中的System.out.println("发生异常");修改为更合理的异常处理逻辑也可以达到正确处理异常的效果。但是如果我们的系统中有很多个线程同时执行不同的代码逻辑时,我们需要对每个线程都进行异常的捕获,而且在程序运行前,身为开发者可能并不知道这段代码可能会产生什么异常。

(五)利用UncauyghtExceptionHander:

UncauyghtExceptionHander接口中只有一个方法:void uncaughtException(Thread t,Throwable e)可以看出这个方法其实就有两个入参,一个异常一个线程,这就可以看出来当某个线程发生异常会被回调由开发者进行处理。

4abe01f89f08d6edbe9c2cadb7c14aa1.png

Java异常处理器(ThreadGroup,实现了Thread.UncaughtExceptionHandler)的调用策略:

510993a65f45f56a448aabe0b95599ce.png

1b51d6da5c9a7757688dfe626e3a6a94.png
public 

看完源码以后就可以确定,开发者需要定义一个开发者自己的异常处理器,在发生异常的时候通过自己定义的异常处理器进行处理。

如何实现一个默认处理器?

1、给程序统一设置

2、给每个线程单独设置

3、给线程池设置

7a8c450c6f888eb07a11649a8b96c73d.png

异常处理器代码:

public 

在实际使用中 public void uncaughtException(Thread t, Throwable e) {}这个方法中根据工作需要具体实现。

(六)思考:run方法能否抛出异常?如果抛出异常,线程的状态会如何?

run方法本身声明时没有声明抛出异常,所以无法向外层抛出异常,只能自己进行try/catch异常,如果线程中出现了之前试验中的RuntimeException它会自己抛出一个异常,终止线程,打印异常堆栈信息。

三、多线程带来的线程安全问题?

(一)线程安全是什么

《Java Concurrency In Practice》中这样定义“当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。”

在这里《Java Concurrency In Practice》中的定义比较清晰,在这里有“不考虑这些线程在运行时环境下的调度和交替执行”、“也不需要进行额外的同步”、“在调用方进行任何其他的协调操作”这三个条件,以及“调用这个对象的行为都可以获得正确的结果”这个结果,满足这三个条件且达成这个结果,则说明线程安全。

也就是说当多个线程切换访问某方法或对象时不需要程序员额外处理都可以完成和单线程一样的顺畅工作。

(二)那么怎么样线程是不安全的呢?

1.多个线程访问同一个资源有读有写的时候可能会导致线程不安全

2.当我们需要做额外处理才保证了线程安全(加锁),说明这个方法或者对象是线程不安全的

(三)那为什么不把所有的资源都搞成线程安全的呢?

如果要保证线程安全,那么就要牺牲运行速度、运行资源或者思考成本。那么当同样的,当一个类在设计初期不会应用到多线程场景时,那么也没必要对它进行线程安全的处理。

(四)什么情况下会出现线程安全问题呢?

(1)当多个线程同时对某个资源进行写操作时,那么可能部分线程的操作丢失:

比如线程A.B,C同时对资源I做加一,当I是0的时候ABC都拿到了数据I,这时ABC都对该资源进行了加一,将1赋值给I,那么最终结果就是I=1。三个线程都加一,那I的最终结果应该是3,,但是显然因为线程安全问题,导致了结果的错误:

b90446697da6f5dfe5c62b04435a3840.png

b839d0c49647091f71167e864a4fe9e2.png

原理如下图所示:

线程A获得资源I时资源I的值为0,A对资源做了自增,在A对资源I的值修改之前线程B获得了资源i的值为0,A将资源I的值修改为1,但此时B认为资源I的值还是刚刚获得的0,所以对i进行了自增,将自增结果赋值给资源I ,线程A的修改丢失了。

2ff85a8b97b4a33e08a4e6a210a3f30a.png

当多个线程同时对某个资源进行读写操作时,可能部分线程所读取到的数据是某一线程进行写操作之前的值,而另一部分线程所读取的数据是写操作之后的值,导致了数据的不一致。

(2)死锁:

public 

运行效果:

1cd87ba3f6896fa91c06740a7f51e561.png

这里创建了两个相互等待的锁,使两个线程都可以进入锁里面占用另一把锁,无法进行后面的工作,陷入了无限等待的状态。

(3)发布或者初始化时产生的逸出:

发布: 即当某个类中的属性被其他类所使用即为发布,public、return、传参。

逸出:即发布到了不该发布的地方,比如:1、方法返回了一个private属性,在外部对该属性进行修改。

bc30dd53d18e16f5947d535e64820ea6.png

要解决这样的问题也很简单,只要不返回这个私有属性,而返回一个它的副本即可,这样就达到了外部只能查看却无法修改的作用。

2d78500bc1f62986589f4d371762f42d.png

2、在构造函数中未初始化时就把对象提供给外界

示例如下:

public 

dec19ebc7f204773475668eb1bb2b601.png

当我们修改main函数中的等待时间,会发现时间不同所输出的值不同,为什么呢?是因为我们在这个类完成初始化前就将其对象抛了出来,导致发布出来的内容不一致,这样说明代码是有隐患的,因为一个对象被抛出来以后应该是一个固定的完整的只要不被操作就没有变化的对象,但是现在因为在不同时间访问导致了所见对象不同,表明这个线程是有安全隐患的。

举例2:

public 

c54ac515c24ea7884089f4254ed79884.png

为什么打印出来的是0而不是100?

因为我们注册监听器时,我们已经暴露了外部类的对象。source.regusterListener(new EventListener() {}这个匿名内部类实际上已经持有了外部类的对象,我们可以直接在这进行打印或者修改,所以当我们未初始化完毕时,注册监听器也是一件很危险的事情。

举例3:

public 

74b58fd52ffe9d302d627afcac8f1e88.png

初始化代码在子线程中实现需要时间,而父线程会认为start执行了后初始化代码在子线程中就执行完毕了,这时父线程在子线程未执行完初始化时访问对象,就会出现空指针,如果等一会儿,在子线程执行完以后访问又可以得到正确数据,那么这样的代码显然非常不稳定。这里需要注意在构造函数中拿取数据库连接池或者线程池的引用的处理,因为创建链接或者线程时,都是构造函数在后台新开线程在完成的。

以上这类问题如何解决呢?可以采用工厂模式,以示例2为例:

1.使用工厂方法将类的初始化完成,包括初始化监听器和初始化完成count值。

2.初始化完毕后将完成对监听者的注册,保证了线程的安全性。

public 

总结:

1.访问共享的变量或者资源,会有并发风险,比如对象的属性,静态变量,共享缓存,数据库等。

2.依赖于顺序的操作,即便每一步本身都是线程安全的,仍然存在并发问题,比如线程需要先读取再根据读取结果执行事情。有可能两步操作间数据会被其他线程修改

3.不同的数据之间存在捆绑关系,是一一对应的,如果只修改其中一个会出现问题。

4.使用其他类的时候对方并未声明自己是线程安全类,比如hashmap标注出是非线程安全类。

(五)多线程可能会导致性能问题

线程调度时产生上下文切换,即线程数超过了cpu核心数时会造成性能问题。

当某一个线程执行到sleep,线程调度器保存现场让另外一个线程进入runnable状态,这个过程就是一个上下文切换,此时的开销有可能比线程执行要大的多,一次上下文切换可能需要消耗5000-10000个线程周期,大约是几微秒。这一过程需要经过以下几个步骤:

(1)线程挂起,将该线程的所有状态(上下文)存储到内存的某个地方。

(2)在内存中检索下一个线程的上下文并将它在寄存器中恢复

(3)执行线程被中断时的代码行(程序计数器所指向的位置)恢复线程运行

这个过程涉及到一些缓存的开销,因为CPU为了加快运行会将线程的数据缓存到CPU中,进行上下文切换后,缓存内容失效需要重新缓存。

为了控制以上的上下文开销和缓存开销,CPU可能会规定最小运行时间:即两次上下文切换之间的时间。

而当发生了抢锁或者IO读取时,会产生比较频繁的上下文切换。

其实编译器会在我们看不到的地方进行一些代码行重排序(优化)、CPU也会做一些缓存,而一些同步锁则会为了数据的正确性而阻止这些事情的发生,导致程序只能使用主存中的缓存数据,这样也会产生一些性能问题。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值