
在Java中获取IP地址时,常常会使用InetAddress类的getLocalHost()方法或getAllByName()方法。这些方法在并发环境下可能会出现线程安全问题。
getLocalHost()方法在某些操作系统上可能会耗时很长,甚至会阻塞当前线程。这可能会导致其他线程长时间无法获取IP地址,从而影响应用程序的性能。为了解决这个问题,可以考虑使用异步编程或者线程池来获取IP地址,避免阻塞当前线程。
getAllByName()方法在某些情况下可能会返回null,这可能是由于DNS查询失败或者网络问题导致的。在并发环境下,多个线程同时调用这个方法,可能会导致一些线程获取不到IP地址,从而引发错误。为了解决这个问题,可以添加重试机制或者增加超时时间,还要对返回结果进行合法性检查。
在并发环境下,多个线程可能会同时获取IP地址,这可能会导致资源争用问题,比如多个线程同时使用同一个Socket连接或者同一个网络接口等。为了解决这个问题,可以使用同步机制,如锁或者信号量,来控制对共享资源的访问。
在获取IP地址的过程中,还需要考虑安全性问题。一些恶意攻击者可能会利用IP地址信息来进行各种攻击,比如DDoS攻击、端口扫描等。在获取IP地址时,需要采取相应的安全性防范措施。
在获取IP地址时,要对输入参数进行合法性检查,避免出现SQL注入、SSRF等安全漏洞。还要对获取的IP地址进行合法性验证,例如检查IP地址是否在合法的范围内,是否属于内网IP地址等。
在使用IP地址时,要注意做好异常处理和错误处理,避免因为IP地址的非法性而导致应用程序崩溃或者出现其他安全问题。还要对获取到的IP地址进行适当的脱敏处理,比如去掉最后一个字节等,以减少信息泄露的风险。
在部署应用程序时,要考虑网络隔离、防火墙配置等安全防护措施,减少应用程序直接暴露在公网上的风险。还要定期对应用程序进行安全检查和漏洞扫描,及时发现和修复存在的安全问题。
在Java中获取IP地址是一个常见的操作,但在并发编程和安全性方面需要注意一些问题。在并发编程中,需要注意线程安全问题,避免阻塞、资源争用等问题;在安全性方面,需要注意输入输出合法性检查、异常处理、信息脱敏等。通过采取适当的解决措施,可以确保应用程序在获取IP地址时既高效又安全。
我们一起学并发编程:Java内存模型(一)基础
简介:
Java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰Java程序员,这一系列几篇文章将揭开Java内存模型的神秘面纱。这一系列的文章大致分4个部分,分别是:
Java内存模型基础,主要介绍内存模型相关基本概念
Java内存模型中的顺序一致性,主要介绍重排序与顺序一致性内存模型
同步原语,主要介绍三个同步原语(synchronized、volatile和final)的内存语义及重排序规则在处理器中的实现
Java内存模型的设计,主要介绍Java内存模型的设计原理,及其与处理器内存模型和顺序一致性内存模型的关系。
一、Java内存模型的基础1.1并发编程模型的两个关键问题在并发编程中需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。
通信——线程之间以何种机制来交换信息。 在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
共享内存:线程之间共享程序的公共状态,通过读写内存中的公共转台进行隐式通信
消息传递:线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信
同步——程序中用于控制不同线程键操作发生相对顺序的机制。
共享内存:同步是显式进行的,由于程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行
消息传递:同步是隐式进行的,由于消息的发送必须在消息的接收之前。
总结:
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明,如果编写多线程程序的Java程序员不理解隐式进行线程之间的通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
1.2Java内存模型的抽象结构Java中所有的实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(文章中用“共享变量”指代)。 局部变量(LocalVariables)、方法定义参数(FormalMethodParameters)和异常处理器参数(ExceptionHandlerParameters)不会在线程之间共享,它们不会存在内存可见性问题,因此也不受内存模型的影响。
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。 从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(LocalMemory),本地内存中存储了该线程以读/写共享变量的副本。 本地内存时JMM的一个抽象概念,并不真实存在。 JMM涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
图示:
Java内存模型的抽象示意图
从上图来看,线程A和线程B之间要通信的话,必须经历下面2个步骤。
线程A把本地内存A中更新过的变量刷新到主内存中
线程B到主内存中去读取线程A之前已更新过的共享变量
图示:
线程之间通信示意图
如上图所示,本地内存A和本地内存B有主内存中共享变量X的副本。 假设初始时,这三个内存中的X的值都是0.线程A在执行时,把更新后的X的值(假设值为1)临时存放在自己的本地内存A中。 当线程A和线程B需要通信是,线程A首先把自己本地内存中修改后的X刷新到主内存中,此时主内存中的X值变为了1.随后,线程B到主内存中去读取线程A更新后的X值,此时线程B的本地内存X的值也更新成了1。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。 JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
1.3从源代码到指令重排序在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为三种类型:
编译器优化的重排序。 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。 现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将对跳指令重叠执行。 如果不存在数据依赖性,处理器可以改变语句对应及其指令的执行顺序。
内存系统的重排序。 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码的最终实际执行的指令序列,会分别经历下面3种重排序,其中1属于编译器重排序,2和3属于处理器重排序。
源代码到最终执行的指令序列示意图
重排序可能会导致多线程程序出现内存可见性问题,对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都需要禁止)。 对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(MemoryBarries,Intel称之为MemoryFence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保障。
1.4写缓冲区和内存屏障1.4.1写缓冲区现代处理器都会使用写缓冲区临时保存向内存中写入的数据。写缓冲区的主要作用:
可以保证指令流水线持续运行,可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
它以批处理的方式方式刷新写缓冲区,以及合并写缓冲区中对统一地址的多次写,减少对内存总线的占用。
常见处理器允许的重排序类型(Y-表示允许两个操作重排序,N-表示处理器不允许两个操作重排序)
处理器\规则Load-LoadLoad-StoreStore-StoreStore-Load数据依赖性SPARC-TSONNNYNx86NNNYNIA64YYYYNPowerPCYYYYN说明:常见处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖性的操作做重排序。 N多的表示处理器拥有相对较强的处理器内存模型。
由于写缓冲器仅仅只对它所在的处理器可见,这个特性会对内存操作的执行顺序产生非常重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
举例说明:
示例项目\处理器ProcessorAProcessorB伪代码a=1;//A1x=b;//A2b=2;//B1y=a;//B2可能运行结果初始状态:a=b=0;处理器允许执行后得到结果:x=y=0;假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果,具体原因如下:
处理器和内存交互
说明:处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1、B1),然后从内存中读取另一个共享变量(A2、B2),最后才把自己写缓冲区中保存的脏数据刷新到内存中(A3、B3)。 当以这种时序执行时,程序就可以得到x=y=0结果。
1.4.2内存屏障为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类:
屏障类型指令示例说明LoadLoadBarriersLoad1;LoadLoad;Load2确保Load1数据的装载先于Load2及所有后续装载指令的装载StoreStoreBarriersStore1;StoreStore;Store2确保Store1数据对其他处理器可见(刷新到主内存)先于Store2及所有后续存储指令的存储LoadStoreBarriersLoad1;LoadStore;Store2确保Load1数据装载先于Store2及后续的存储指令刷新到内存StoreLoadBarriers****Store1;StoreLoad;Load2确保Store1数据对其他处理器变得可见(指刷新到主内存)先于Load2及所有后续装载指令的装载。StoreLoadBarriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行屏障之后的内存访问指令。StoreLoadBarriers是一个“全能型屏障”,它同时具有其它3个屏障的效果。 现代大多数处理器支持该屏障(其他类型的屏障不一定被所有处理器支持)。 执行该屏障开销会很昂贵,因为处理器需要把缓冲区的内容全部刷新到内存中(BufferFullyFlush)。
1.5happens-before简介从JDK1.5开始,Java使用新的JSR-133内存模型。 JSR-133使用happens-before的概念来阐述操作之间的内存可见性。 在JMM中,如果一个操作的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。 这里的两个操作可以是单线程也可以是多线程。
happens-before规则:
程序顺序规则:一个线程中的每个操作,happens-before于该线程的任意后续操作。
监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对于一个volitale域的写,happens-before于任意后续对这个volatile域的读。
传递性:如果Ahappens-beforeB,且Bhappens-beforeC,那么Ahappens-beforeC。
注意:
两个操作之间具有happens-before关系,并不意味着前一个操作必须在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(thefirstisvisiabletoandorderedbeofrethesecond)。
图示happens-before与JMM的关系:
happens-before与JMM的关系
一个happens-before规则对应于一个或多个编译器个处理器重排序规则。 对于Java程序员来说,happens-before规则简单易懂,它避免了Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
文章总结至《Java并发编程艺术》,下篇总结“重排序”,敬请关注。
Java 并发编程基础:线程安全与竞态条件
highlight: darcula theme: awesome-green本专栏的学习笔记整理自机械工业出版社的《Java 并发编程实战》。
本章小结什么时候要考虑线程安全?
先检查后执行 ( Check-Then-Act ) 策略带来的竞态条件
原子操作的定义
锁的基本使用
进程是资源分配的最小单位,而线程是 CPU 执行的最小单位。 一个进程内存在多个线程,这些线程共享进程范围内的资源 ( 文件句柄,内存句柄 ),但是每个线程都有独立的程序计数器 ( Program Counter ),操作数栈,局部变量表。 见笔者的 JVM 笔记:JVM :Java 内存区域划分 - 掘金 () 和 JVM:字节码执行引擎 - 掘金 ()。
同一个进程内的多个线程共享同一块地址空间,它们可以访问并修改同一个内存空间的变量。 在不设置任何协同机制的前提下,所有的线程都将独立运行。 每个线程都是 自私 的:当它修改某个变量时完全不会考虑其它线程是否也会对这相同的变量进行读取或者修改。
基础问题构建稳定的并发程序,必须正确使用线程和锁,而这终归只是用来实现并发编程的一些机制。 要编写线程安全的代码,核心是对共享 ( Shared ) 和可变 ( Mutable ) 状态 的访问控制。
共享:变量可以被多个线程访问。
可变:变量的值可以在生命周期内发生变化。
当我们使用 状态 一词描述域 ( 或称成员变量,属性等 ) 时,意味着它是一个可变变量 ( variable ),而非值 ( final value )。 除此之外,状态包含了其字段所能描述的所有数据。 比如:某个 HashMap 域的状态不仅包括它自身的引用,还包括该 HashMap 内部的所有 Entry 的状态。
//?hm?被?final?关键字修饰,但它事实上仍是可变状态。public?final?HashMap<String,String>?hm?=?new?HashMap<>();public?void?addKey(String?k,?String?v){????(k,v);}想要让状态是线程安全的,则必须要引入锁机制加以保护。 Java 提供了多种关键字 synchronized,原子变量,volatile,显式锁等工具实现目的,本章会介绍前两者。
关于线程安全性当我们探讨线程安全性时,实际上是探讨程序在并发环境下执行的 正确性。 展开来讲就是:某个对象的行为和规范完全一致。 我们会定义两种规范:
不变性条件 ( Invariant Conditions ),即总是成立而不发生改变的条件,可以称 性质,用以约束对象的状态。 比如:当银行的账户 A 向 B 发起一笔转账时,两个账户的余额之和应该总是不变的。
后验条件 ( Post Conditions ),在执行一个操作后满足的条件,用于描述对象操作的结果。 比如:账户 B 收到一笔转账后,它现在的余额应该是之前的余额与交易额之和。
并不是所有的并行程序都要考虑并发安全问题。 如果多个线程各自操作不同的对象而不共享任何状态,那么这段代码实际上是并行 ( parallel ) 而非并发 ( concurrent ) 的。
在面向控制的大型系统中,通常考虑的都是并发问题,因为系统依赖于各种全局变量维护状态;除了基于锁机制的维护并发系统之外,还有基于 Actor 模型的并发系统,比如 Akka ( 基于 Actor 的并发控制要比基于锁的并发控制更加简单 )。 而在面向计算的大型系统中,则更偏向于并行调度问题,比如 Spark。
此外,无状态对象一定是线程安全的。 无状态对象不包含任何域,也不包含任何对其它域的引用。
见下方的 build 方法:它本身就是独立的闭包 ( closure ),内部不引用任何自由变量。 即便是多个线程访问了同一个 ArrayBuilder 对象的 build 方法,局部变量 count 也是被保存在不同的栈帧下的局部变量表内的。 线程之间没有共享状态,因此不会相互干涉计算结果。
class?ArrayBuilder{????public?void?build(int[]?arr){????????int?count?=?0;????????for(int?i?=?0?;?i?<=??-?1?;?i++,count++)?{????????????arr[i]?=?i;????????????(count?=?%s\n,count);????????}????}}总的来说,想要保证一个变量是线程安全的,有以下三种方法:
不在线程之间共享这个变量。
将变量设置为不可变的。
访问 / 修改变量时引入同步机制。
显而易见的是,如果一个对象在单线程都不具备正确性,那它也一定不是多线程安全的。
活跃性问题与性能问题安全性探讨 永远不要发生糟糕的事情,活跃性探讨 正确的事情应当尽快发生。 比如说:为了保证线程安全,线程 A 正等待另一个线程 B 释放某个互斥锁。 如果线程 B 一直不释放资源,那么线程 A 就只能一直等待下去。 活跃性问题包括死锁,饥饿,活锁等。 导致活跃性问题的错误是难以分析的,因为这依赖于不同线程的事件发生时序,因此很难在单元测试中复现。
在设计良好的并行程序中,多线程能够提升程序的性能,但是,使用多线程会不可避免地带来更多的运行时开销。 当调度器暂时挂起活跃进程并运行另一个线程时,需要执行上下文切换 ( Context Switch ) 操作。
如果并行程序设计的不当,那么 CPU 会被迫将大部分时间花在上下文切换而不是执行任务上。 同时,当线程之间共享变量时,必须采取同步机制,而这些机制又会抑制某些编译器优化,使得内存缓冲区的数据无效。 可见,活跃性问题还和性能问题密切相关,而这些内容在之后的学习中再逐步探讨。
原子性如果将前文例子的 count 字段挪到实例域内,那么ArrayBuilder 类创建出的对象就是携带状态的了。
class?ArrayBuilder{????//?count?现在被用来记录?build?方法被调用的次数????public?int?count?=?0;????public?void?build(int[]?arr){????????for(int?i?=?0?;?i?<=??-?1?;?i++)?{????????????arr[i]?=?i;????????}????????count++;????}}此时,build 方法内 count 是一个自由变量 ( 是一个不受 build 方法约束的变量 )。 count 被保存到字段表 ( 而不是局部变量表 ),同一个对象的 count 可以被多个线程观察并修改。
在单线程环境中,这是一段再普通不过,且完全合理的代码。 但是在多线程环境中这么做,ArrayBuilder 很可能会丢失一些更新记录。 count++ 是一个紧凑的语法,因此看起来这像是一个操作,但事实上并非如此:它包含了:读取 → 修改 → 写入 三个独立的步骤,每一步都依赖上一步的状态。
设想一下,两个线程同时读取了 count = 0,接着执行递增操作,然后两个线程同时将 count 值修改为 1。 显然,这里的计数器发生了偏差。 随着冲突次数的增加,计数器的偏差将会越来越大。 这种由于不恰当的执行时序而出现不正确的结果有一个官方称呼:竞态条件 ( Race Condition )。
竞态条件当某个计算的正确性取决于多个线程的交替执行次序时,竞态条件就会发生。 再通俗地说:程序的正确性取决于运气。 最常见的竞态条件发生在 先检查后执行 ( Check-Then-Act ) 类型的代码中,因为线程可能会基于已经失效的观测结果执行下一步动作。
比如某个线程在开始时观察到某个文件 X 并不存在,并打算新创建一个文件 X 并写入一些内容。 但是,在程序创建文件之前,其它程序或者用户可能创建了它 ( 该线程最开始的观测结果相当于失效了 )。 这会导致各种问题:未预期的异常,数据被覆盖等。
示例:延迟初始化先检查后执行 的一种典型场景延迟初始化。 延迟初始化的本意是让创建成本高昂的对象被推迟到只有在真正需要时才会被加载。
class?LazyInitRace?{????//?实际上,这个?Object?可能是一个创建代价昂贵的类型。????private?Object?instance?=?null;????public?Object?getInstance(){????????//?if()?是一个明显的观测动作????????if(instance?==?null)?instance?=?new?Object();????????return?instance;????}}显然,这是一个 先检查后执行“ 的过程:线程 A 在访问 getInstance() 方法时,会率先观察 instance 是否为 null,然后再决定是初始化,还是直接返回引用;另一个到达的线程 B 要做相同的检查。 但现在,它所观察到的 instance 实际是否为空取决于不可预测的时序,还包括线程 A 需要花费多长的时间来初始化 instance。 如果线程 B 无意间做出了误判,那么整个流程会错误地创建出两个 instance 实例。
附:当一个类首次被加载时,其静态域会执行一次。 可以利用这个特性实现线程安全的单例模式。
class?LazyInitRace?{????//?即使?LazyInitRace?被加载了,Instance$?也未必加载,直到某个?LazyInitRace?对象的?getInstance?方法被首次调用:????private?static?class?Instance${private?static?final?Object?instance_?=?new?Object();}????public?Object?getInstance(){return?Instance$_;}}复合操作想要避免竞态条件,就必须要引入原子操作。 比如两个线程 A 和 B 正同时修改同一个状态,当任意一个线程要开始操作时,另一个线程要么已经完整执行了访问 ?→ 修改 → 写入的流程,要么就还没有开始。 此时 A 和 B 两线程的修改状态操作就是原子的。 原子操作针对的是 同一个状态 的访问与修改。
如果对状态 count++ 的操作是原子的,那么原本存在的竞态条件就不存在了。 我们将访问 → 修改 → 修改这三个流程统称为一个复合操作,将这个复合操作变成一个原子操作,以保证线程安全性。
class?ArrayBuilder{????private?final?AtomicInteger?count$?=?new?AtomicInteger(0);????//?count()?记录?build?方法被调用的次数????public?int?count(){?return?count$();}????public?void?build(int...?arr){????????for(int?i?=?0?;?i?<=??-?1?;?i++)?{arr[i]?=?i;}????????count$();????}}下本身包含了一些用于提升普通数值类型的原子变量类。 在本例中,AtomicInteger 能够保证对 count$ 的所有操作都是原子的。 而又因为目前的 ArrayBuilder 只维护了一个 count$ 状态,此时也可以称 ArrayBuilder 类本身是线程安全的。
尽可能使用线程安全的对象管理状态,会使得并发任务的代码更加易于维护。
加锁机制假设现在的类内部定义了更多的状态,复合操作变得更加复杂,只是简单地添加更多的原子变量就足够了吗?为了说明这个问题,下面仍以银行转账为例子,Bank 类维护两个状态:A,B 两个账户的余额。
class?Bank?{????//?此处的?final?指代?account_A?和?account_B?的引用不可改变。????public?final?AtomicInteger?account_A?=?new?AtomicInteger(100);????public?final?AtomicInteger?account_B?=?new?AtomicInteger(200);????public?void?transform(AtomicInteger?x,?AtomicInteger?y,int?amount)?{????????var?a_?=?account_();????????var?b_?=?account_();????????account_(a_?-?amount);????????account_(b_?+?amount);????}}线程安全性规定,无论多个线程的操作以什么样的时序交替执行,都不能破坏类的不变性条件。 比如在转账业务中的不变性条件之一是:任何线程在开始时观察到的 A,B 两个账户余额之和应该相等。
尽管两个引用 account_A 和 account_B 都是线程安全的,但是在 Bank 类当中仍然存在着竞态条件,这可能引发错误的结果,原因在于线程无法同时修改两个账户的金额。 比如另一个线程 T2 在 T1 线程 刚刚写入 A 账户但未写入 B 账户 的时机进入,它观察到的不变性准则就未必成立。
因此,想要保持状态的一致性,就需要在单个原子操作中更新所有状态相关的状态变量。
Java 从语法层面上支持原子性:synchronized 关键字,又被称之为 内置锁。它可以单独作为一个语法块使用,写法如:
synchronized(lock){/**/}任何一个 Java 对象都可以作为互斥的同步锁 lock。 在进入同步代码块之前,线程必须要先获得 lock 锁,然后在退出同步代码块 ( 包括抛出异常退出 ) 时释放它。 当另一个线程 T2 想要获取 T1 正在持有的锁对象 lock 时,它必须要阻塞等待。 如果 T1 一直不释放 lock,那么 T2 会无限期地等待下去。 通过这种形式,Java 保证了同一时刻只能有一个线程执行同步代码块。
可以直接将被保护的变量作为 lock 放入其中,此时称该状态是被锁保护的;也可以将整个对象 ( this ) 作为一个锁放入其中,此时该对象的所有可变状态都会受到保护。
synchronized 关键字还可以直接标注在方法声明上。 被标注的方法相当于是一个横跨整个函数体的同步代码块,而该代码块的锁就是被调用方法的对象本身 ( this )。 如果是静态的同步方法,那么就会以描述该对象的类信息的 Class 对象作为锁。
class?Bank?{????//?不变性条件:A?B?两个账户的余额之和不变。????//?account_A?和?account_B?被内置锁保护,因此没有必要再单独设置成原子变量了。????public?Integer?account_A?=?100;????public?Integer?account_B?=?200;????public?synchronized?void?transform(Integer?amount)?{????????account_A?=?account_A?-?amount;????????account_B?=?account_B?+?amount;????}}重入特性获取锁操作的粒度是线程,而非调用。 在 Java 中,线程可以反复访问由它自己持有的锁,这种特性称之为锁的可重入性。
class?MutexFoo?{????//?这里的锁就是?MutexFoo?实例本身????public?synchronized?void?f(){g();}????public?synchronized?void?g(){}}重入的一种实现方式是:为每把锁设置一个计数器并记录持有者线程。 持有者线程可以反复重入,每次计数器都会累加;每释放一次,计数器减 1。 当计数器数值为 0 时,说明这把锁目前处于释放状态。
显然,在调用 MutexFoo 对象的 f 方法时,线程需要对此对象上两次锁。 假如 Java 将锁设计成了不可重入的,那么下面的代码就会产生死锁。
class?ArrayBuilder{????public?void?build(int[]?arr){????????int?count?=?0;????????for(int?i?=?0?;?i?<=??-?1?;?i++,count++)?{????????????arr[i]?=?i;????????????(count?=?%s\n,count);????????}????}}0加锁的约定一种常见的加锁协议是:将所有的可变状态全部封装到对象内部,然后通过对象的 synchronized 内置锁在 所有 访问可变状态的代码块进行同步。 开发者必须要小心,如果在某处遗漏了同步规则,那么整个加锁协议就会失效。
对于一个原子操作所涉及的多个变量都必须使用同一个内置锁来保护,比如说转账业务的 account_A 和 account_B,它们都由 Bank 对象自身的内置锁来保护。
并非所有的数据都需要锁的保护,只有会被多线程同时访问的状态才需要上锁,这同时涉及到了锁粒度的问题。假设 Bank 在转账之前会临时通过购买的单价和数量计算交易额:
class?ArrayBuilder{????public?void?build(int[]?arr){????????int?count?=?0;????????for(int?i?=?0?;?i?<=??-?1?;?i++,count++)?{????????????arr[i]?=?i;????????????(count?=?%s\n,count);????????}????}}1由于内置锁加在了 transform 方法上,因此多个线程必须以完全串行的方式工作 ( 假定这里仅使用一个 Bank 对象 ),整段代码的执行性能将会非常糟糕。 我们实际上注意到,amount 是一个局部变量,它本身就不会被任何线程共享,因此完全没必要加入到同步代码块内。 下面是优化代码:
class?ArrayBuilder{????public?void?build(int[]?arr){????????int?count?=?0;????????for(int?i?=?0?;?i?<=??-?1?;?i++,count++)?{????????????arr[i]?=?i;????????????(count?=?%s\n,count);????????}????}}2这样,即便线程发现不能够立刻修改 A,B 账户的状态,也可以率先计算出转账的金额,而不是傻傻地等待其它线程完全执行完之后再计算。 尤其是某些局部变量需要大量运算时,精细化封锁粒度能够在性能和安全之间找到良好的平衡。
如果持有锁的时间过长,那么性能问题会变得非常显著。 因此,一定不要在同步代码块内部做严重耗时的操作。
并发编程如何入门?看看这篇文章
简介:并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度的并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行的更快,会面临非常多的挑战,比如上下文切换的问题、死锁问题,以及受限于硬件和软件的资源限制问题,本篇文章介绍几种并发编程的挑战及解决方案,文章总结至《Java并发编程的艺术》
一、上下文切换即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。 时间片是CPU分配给各个线程执行的时间,因为时间片非常短,所有CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。 但是,在切换前会保存上一次任务的状态,以便于下次切换回这个任务时,可以再加载这个任务的状态。 所以任务从保存到再加载的过程就是一次上下文切换。
这就像我们同事读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本英文技术书。 这样的切换时会影响读书效率的,同样的道理上下文的切换也会影响多线程的执行速度。
1.1多线程一定快吗下面的代码演示串行和并发执行并累加操作的时间,分析并发执行一定比串行执行快么?
.p1;/***<p>*测试并发执行和串行的速度*</p>**@Author:Liziba*@Date:2021/6/223:40*/publicclassConcurrencyTest{/**执行次数*/privatestaticfinallongcount=;publicstaticvoidmain(String[]args)throwsInterruptedException{concurrency();serial();}/***并发执行*@throwsInterruptedException*/privatestaticvoidconcurrency()throwsInterruptedException{longstart=();Threadthread=newThread(newRunnable(){publicvoidrun(){inta=0;for(longi=0;i<count;i++){a+=5;}}});();intb=0;for(longi=0;i<count;i++){b--;}();longtime=()-start;(concurrency:+time+ms,b=+b);}/***串行执行*/privatestaticvoidserial(){longstart=();inta=0;for(longi=0;i<count;i++){a+=5;}intb=0;for(longi=0;i<count;i++){b--;}longtime=()-start;(serial:+time+ms,b=+b);}}时间统计
循环次数串行执行耗时/ms并发执行耗时/ms并发比串行快多少1万05慢10万23慢100万34差不多1000万87差不多1亿5454差不多10亿差不多从上表可以看出,当并发执行累计操作低于百万次时,速度会比串行执行累加操作要慢。 为什么在这种情况下并发执行比串行执行要慢呢?这是因为创建线程和上下文切换的时间开销要远远大于简单计算的时间开销。
1.2测试上下文切换次数和时长测试工具:
使用Lmbench3可以测量上下文切换的时长
使用vmstat可以测量上下文切换的次数
vmstat参数的含义:
参数名含义r表示运行队列(就是说多少个进程真的分配到CPU)b表示阻塞的进程swpd虚拟内存已使用的大小,如果大于0,表示你的机器物理内存不足了,如果不是程序内存泄露的原因,那么你该升级内存了或者把耗内存的任务迁移到其他机器。free空闲的物理内存的大小buffLinux/Unix系统用来存储,目录里面有什么内容,权限等的缓存cache用来记忆我们打开的文件,给文件做缓冲si每秒从磁盘读入虚拟内存的大小,如果这个值大于0,表示物理内存不够用或者内存泄露了,要查找耗内存进程解决掉so每秒虚拟内存写入磁盘的大小,如果这个值大于0,同上bi块设备每秒接收的块数量,这里的块设备是指系统上所有的磁盘和其他块设备,默认块大小是1024bytebo块设备每秒发送的块数量,例如我们读取文件,bo就要大于0。bi和bo一般都要接近0,不然就是IO过于频繁,需要调整in每秒CPU的中断次数,包括时间中断cs每秒上下文切换次数us用户CPU时间sy系统CPU时间,如果太高,表示系统调用时间长,例如IO操作频繁wt等待IOCPU时间#每隔一秒采集数据,一直采集,直到程序终止vmstat1CS(ContentSwitch)表示上下文切换的次数,从上面的可以看出上下文每秒钟切换1000多次。
1.3如何减少上下文切换减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
无锁并发编程。 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的id按照Hash算法取模分段,不同的线程处理不同段的数据。
CAS算法。 Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
使用最少线程。 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程处于等待状态。
协程。 在单线程里实现多任务调度,并在单线程里维持多个任务见的切换。
1.4减少上下文切换实战这个例子简单说明如何来减少线程池中大量WAITING线程,来减少上下文切换次数。(本文在Windows环境dump测试)
写一个模拟出现WAITING状态的代码:
.p1;;;/***<p>*线程池Dump测试--代码只是示例*</p>**@Author:Liziba*@Date:2021/6/423:26*/publicclassThreadPoolDumpTest{publicstaticvoidmain(String[]args){//创建固定大小的线程池ExecutorServicefixedThreadPool=(300);//初始化线程池中的线程for(inti=0;i<300;i++){(getThread(i));}while(true){try{(5000);}catch(InterruptedExceptione){();}(测试!);}}/***创建线程*@parami*@return*/privatestaticRunnablegetThread(finalinti){returnnewRunnable(){publicvoidrun(){try{(50);}catch(InterruptedExceptione){();}(i);}};}}用jstack命令dump线程信息,可以看当前运行的Java程序的pid,查看当前进程号里的线程在做什么。
#查看Java进程jps结果:
RemoteMavenServer36
ThreadPoolDumpTest
#dump下快照jstack-l>d:\打开dump文件查看处于(onobjectmonitor)阻塞的线程在做什么。
发现有300个线程处于WAITING状态
pool-1-thread-300#311prio=5os_prio=0tid=0xfenid=0x4880waitingoncondition[0xcfe000]:WAITING(parking)(NativeMethod)-parkingtowaitfor<0xb>($ConditionObject)(:175)$(:2039)(:442)(:1074)(:1134)$(:624)(:748)Lockedownablesynchronizers:-None此时如果发现是我们在程序中定义的线程池中的线程,则我们应该适当考虑降低线程池的maxThreads的值。
此处示例中我们修改线程池的固定大小为10:
//创建固定大小的线程池ExecutorServicefixedThreadPool=(10);修改maxThread值之后我们可以重启项目。 再次dump线程信息,然后重新统计(onobjectmonitor)阻塞的线程数。
再次dump快照分析线程运行情况,发现只有10个线程处于WAITING状态了:
pool-1-thread-10#21prio=5os_prio=0tid=0xecde000nid=0x312cwaitingoncondition[0xef000]:WAITING(parking)在上面的简单案例中WAITING线程减少了,系统上下文切换的次数就会减少,因为每一次从WAITING到RUNNABLE都会进行一次上下文的切换。 在实际开发中,我们并不会做这么看似低级的操作,但是样例却能给我们代理线程池优化和程序线程优化各方面的解决问题的思路。
二、死锁锁是一个非常有用的工具,运用的场景非常多,因为它使用起来非常简单,而且易于理解。 但同时它会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用。
2.1死锁示例下面演示一段引起死锁的代码,使得线程t1和线程t2互相等待对方释放锁。
.p1;/***<p>*死锁示例代码*</p>**@Author:Liziba*@Date:2021/6/50:37*/publicclassDeadLockDemo{privatestaticfinalStringA=A;privatestaticfinalStringB=B;/***t1\t2互相持有锁*/privatevoiddeadLock(){Threadt1=newThread(newRunnable(){publicvoidrun(){//持有锁Asynchronized(A){try{()(2000);}catch(InterruptedExceptione){();}//持有锁Bsynchronized(B){(holdLockB);}}}});Threadt2=newThread(newRunnable(){publicvoidrun(){//持有锁Bsynchronized(B){try{()(2000);}catch(InterruptedExceptione){();}//持有锁Asynchronized(A){(holdLockA);}}}});();();}publicstaticvoidmain(String[]args){newDeadLockDemo()();}}这段代码演示的是简单的死锁场景,在现实中大家都不会写出这样的代码。 但是,在一些更为复杂的场景中,你可能会遇到这样的问题,比如t1拿到锁之后,因为一些异常情况并没有释放锁(比如死循环)。 又或者t1拿到一个数据库锁,释放锁的时候抛出了异常,没有释放掉。
现实中,一旦出现了死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程查看到底是哪个线程出现了问题,我们分析如下Dump出的线程信息:
Thread-1#13prio=5os_prio=0tid=0xenid=0x5318waitingformonitorentry[0xfcef000]:BLOCKED(onobjectmonitor)$(:50)-waitingtolock<0xb>()-locked<0xb>()(:748)Lockedownablesynchronizers:-NoneThread-0#12prio=5os_prio=0tid=0xe00f800nid=0x4b38waitingformonitorentry[0xfbef000]:BLOCKED(onobjectmonitor)$(:33)-waitingtolock<0xb>()-locked<0xb>()(:748)Lockedownablesynchronizers:-None从上可以看出第33行和第50行引发了死锁。
2.2避免产生死锁避免一个线程同时获取多个锁。
避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
尝试使用定时锁,使用(timeout)来替代使用内部锁机制。
对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
三、资源限制3.1什么是资源限制资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或者软件资源。 例如,服务器的带宽只有2MB/s,某个资源的下载速度是1MB/s,系统启动10个线程下载资源,下载速度不会变成10MB/s,所以在并发编程时,要考虑这些资源的限制。
硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU处理速度。
软件资源的限制有数据库的连接和socket连接数等。
3.2资源限制引发的问题在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这样程序不仅不会加快,反而会更慢,因为增加上下文切换和资源调度的时间。
3.3如何解决资源限制的问题对于硬件资源的限制,可以考虑使用集群并行执行程序
对应软件资源的限制,可以考虑使用资源池将资源复用
3.4在资源限制情况下并发编程如何在资源限制的情况下,让程序执行的更加快呢?方法就是,根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源-宽带和硬盘的读写速度。 有数据库操作时,涉及数据库连接,如果SQL执行非常快,而线程的数量比数据量连接数大很多,则某些线程会被阻塞,等待数据库连接。
本文总结至--《Java并发编程的艺术》/《TheArtofJavaConcurrencyProgramming》