面试被问线程安全怎么保障,我的回答让面试官眼前一亮

用户头像
996小迁
关注
发布于: 2020 年 12 月 07 日
面试被问线程安全怎么保障,我的回答让面试官眼前一亮

数据量的爆发,网民的增加,导致的就是网民对于用户体验需求的增加,同样的,大用户量的涌入,但是数据量和并发量的增加,也会出现数据污染的情况,那这个时候,保障线程安全就显得尤为重要,我今天会从Java运行时的存储空间开始整理,给大家讲明白线程安全该如何保障

话不多说,看正文

 Java运行时存储空间

了解Java运行时存储空间的有关知识有助于我们更好地理解多线程编程。Java运行 时(Java Runtime )空间可以分为堆(Heap )空间、栈(Stack )空间和非堆(Non-Heap ) 空间。其中,堆空间和非堆空间是可以被多个线程共享的,而栈空间则是线程的私有空间, 每个线程都有其栈空间,并且一个线程无法访问其他线程的栈空间。

堆空间(Heap space)用于存储对象,即创建一个实例的时候该实例所需的存储空间 是在堆空间中进行分配的,堆空间本身是在Java虚拟机启动的时候分配的一段可以动态 扩容的内存空间。因此,类的实例变量是存储在堆空间中的。由于堆空间是线程之间的共 享空间,因此实例变量以及引用型实例变量所引用的对象是可以被多个线程共享的。不管 引用对象的变量的作用域如何(局部变量、实例变量和静态变量),对象本身总是存储在 堆空间中的。堆空间也是垃圾回收器(Garbage Collector )工作的场所,即堆空间中没有 可达引用的对象(不再被使用的对象)所占用的存储空间会被垃圾回收器回收。堆空间通 常可以进一步划分为年轻代(Young Generation )和年老代(Old/Tenured Generation )。对 象所需的存储空间是在年轻代中进行分配的。垃圾回收器对年轻代中的对象进行的垃圾回 收被称为次要回收(Minor Collection )o次要回收中“幸存”下来(即没有被回收掉)的 对象最终可能被移入(改变对象所在的存储空间)年老代。垃圾回收器对年老代中的对象 进行的垃圾回收被称为主要回收(Major Collection )。



栈空间(Stack Space )是为线程的执行而准备的一段固定大小的内存空间,每个线程 都有其栈空间|。栈空间是在线程创建的时候分配的。线程执行(调用)一个方法前,Java 虚拟机会在该线程的栈空间中为这个方法调用创建一个栈帧(Frame )。栈帧用于存储相应 方法的局部变量、返回值等私有数据。可见,局部变量的变量值存储在栈空间中。基础类 型(Primitive Type )变量和引用类型(Reference Type )变量的变量值都是直接存储在栈 帧中的2。引用型变量的值相当于被引用对象的内存地址,而引用型变量所引用的对象仍 然在堆空间中。也就是说,对于引用型局部变量,栈帧中存储的是相应对象的内存地址而 不是对象本身!由于一个线程无法访问另外一个线程的栈空间,因此,线程对局部变量以 及对只能通过当前线程的局部变量才能访问到的对象进行的操作具有固有(Inherent)的 线程安全性。

非堆空间(Non-HeapSpace)用于存储常量以及类的元数据(Meta-data )等,它也是 在Java虚拟机启动的时候分配的一段可以动态扩容的内存空间。类的元数据包括类的静 态变量、类有哪些方法以及这些方法的元数据(包括名称、参数和返回值等)。非堆空间 也是多个线程之间共享的存储空间。类的静态变量在非堆空间中的存储方式与局部变量在 栈空间的存储方式相似,即这些空间中仅存储变量的值本身,而引用型变量所引用的对象 仍然存储在堆空间中。

public class JavaMemory (
public static void main(String[] args) (
String msg = args. length > 0 ? args [0] : null;
ObjectX objX = new ObjectX();
objX.greet(msg);
}
}
class ObjectX implements Serializable {
private static final long serialVersionUID = 8554375271108416940L;
private static Atomiclnteger ID_Generator = new Atomiclnteger(0);
private Date timeCreated = new Date();
private int id;
public ObjectX () (
this.id = ID_Generator.getAndlncrement();
}
public void greet(String message) (
String msg = toString () + **: ** + message;
Debug.info(msg);
}
@Override
public String toString() (
return *' [n + timeCreated + n ] ObjectX [n + id + " ] n ;





大公无私:无状态对象

对象(Object)就是操作和数据的封装。对象所包含的数据就被称为该对象的状态 (State ),它包括存储在实例变量或者静态变量之中的数据。一个对象的状态也可能包含该 对象引用的其他对象的实例变量或者静态变量中的数据。相应地,实例变量、静态变量也 被称为状态变量(State Variable )。如果一个类的同一个实例被多个线程共享并不会使这些 线程存在共享状态(Shared State ),那么这个类及其任意一个实例就被称为无状态对象 (Stateless Object )o反之,如果一个类的同一个实例被多个线程共享,会使这些线程存在 共享状态,那么这个类及其任意一个实例就被称为有状态对象(Stateful Object )o无状态 对象不含任何实例变量,且不包含任何静态变量或者其包含的静态变量都是只读的(常 量)。有状态对象又可以分为状态可变对象和状态不可变对象。所谓状态可变就是,对象 在其生命周期中,其状态变量的值可以发生变化。

我们知道线程安全问题产生的前提是多个线程之间存在共享数据。因此,实现线程安 全的一种自然的方法就是避免在多个线程之间共享数据。使用无状态对象就是这样一种自 然的办法:一个线程执行无状态对象的任意一个方法来完成某个计算的时候,该计算的瞬 时状态(中间结果)仅体现在局部变量和(或)只有当前执行线程能够访问的对象的状态 ±o因此,一个线程执行无状态对象的任何方法都不会对访问该无状态对象的其他线程产 无任何干扰作用。所以,无状态对象具有固有的线程安全性,它可以被多个线程共享,而 这些线程在执行该对象的任何方法时都无须使用同步机制。

下面看一个无状态对象使用实例。

public class DefaultEndpointComparator implements Comparator<Endpoint> (
@Override
public int compare(Endpoint serverl, Endpoint server?) (
int result = 0;
boolean isOnlinel = serverl.isOnline ();
boolean is0nline2 = server?.isOnline ();
//优先按照服务器是否在线排序
if (isOnlinel == is0nline2) {
//被比较的两台服务器都在线(或不在线)的情况下进一步比较服务器权重 result = compareWeight(serverl.weight, server?.weight);
} else (
//在线的服务器排序靠前
if (isOnlinel) (
result = -1;
}
1
return result;
}
private int compareWeight(int weightl, int weight2) {
// ...
}
}

DefaultEndpointComparator 的实例就是一个无状态对象:DefaultEndpointComparator. compare方法执行时所产生的瞬时状态仅体现为局部变量以及只有执行线程才能访问的对 象(Endpoint实例)。在此基础上我们可以实现排序,如清单6-3所示。一个 DefaultEndpointComparator 实例可以被 EndpointView.retrieveServerList。的多个执行线程 共享(通过静态变量DEFAULT_COMPARATOR ),而这些线程无须使用锁等同步机制。

public class Endpointview (
static final Comparator<Endpoint> DEFAULT_COMPARATOR;
static {
DEFAULT_COMPARATOR = new DefaultEndpointComparator();
}
//省略其他代码
public Endpoint[] retrieveServerList(Comparator<Endpoint> comparator) (
Endpoint[] serverList = doRetrieveServerList();
Arrays.sort(serverList, comparator);
return serverList;
}
public Endpoint[] retrieveServerList () (
return retrieveServerList(DEFAULT_COMP/kRATOR);
}
private Endpoint [] doRetrieveServerList() {
// ...
}
public static void main(String[] args) {
Endpointview endpointview = new Endpointview();
Endpoint[] serverList = endpointview.retrieveServerList();
Debug.info(Arrays.toString(serverList));

无状态对象具有线程安全性,这有两层含义:首先,无状态对象的客户端代码在调用 该对象的任何方法时都无须加锁。例如EndpointView.retrieveServerList()在访问 DefaultEndpointComparator实例的时候无须加锁。其次,无状态对象自身的方法实现也无 须使用锁。例如,DefaultEndpointComparator.compare方法中没有使用任何锁。

正如清单6-2的代码所展示的那样,无状态对象(以及该类的任何一个上层类)是不 包含任何实例变量或者任何可更新的静态变量的3。但是,有时候我们可能很难找到一个 像DefaultEndpointComparator实例那样“纯粹”的无状态对象 个类即使不包含任何 实例变量或者静态变量,执行这个类方法的多个线程仍然可能存在共享状态,

public class BrokenStatelessObj ect {
public String doSomething(String s) (
UnsafeSingleton us = UnsafeSingleton.INSTANCE;
int i = us.doSomething(s);
UnsafeStatefullObject sfo = new UnsafeStatefullObject ();
String str = sfo.doSomething(s, i);
return str;
}
public String doSomethingl(String s) (
UnsafeSingleton us = UnsafeSingleton.INSTANCE;
UnsafeStatefullObject sfo = new UnsafeStatefullObject();
String str;
synchronized (this) (
str = sfo.doSomething(s, us.doSomething (s));
}
return str;
class UnsafeStatefullObject (
static Map<String, String> cache = new HashMap<String, String>();
public String doSomething(String s, int len) (
String result = cache.get(s);
if (null == result) (
result = md5sum(result, len);
cache.put(s, result);
}
return result;

)

public String md5sum(String s, int len) ( //生成md5摘要

//省略其他代码

return s;

}

}

enum UnsafeSingleton (

INSTANCE;

public int statel;

public int doSomething(String s) (

//省略其他代码

// 访问 statel

return 0;

} }



尽管BrokenStatelessObject类自身不包含任何实例变量或者静态变量,但是 BrokenStatelessObjec.doSomething方法的多个执行线程仍然可能存在共享状态。 BrokenStatelessObjec.doSomething方法中使用的UnsafeSingleton是一个非线程安全单例类 (该类仅有一个实例 UnsafeSingleton.INSTANCE )。因此,BrokenStatelessObjec.doSomething 方法的多个执行线程其实是在共享同一个UnsafeSingleton实例,而UnsafeSingleton类的 实例变量statel就成为这些线程的共享状态。尽管BrokenStatelessObjec.doSomething方法 的多个执行线程各自都访问各自的UnsafeStatefullObject实例,但是UnsafeStateftillObject 的静态变量cache会成为这些线程的共享状态。因此,即使一个类不包含任何实例变量或 者静态变量,执行该类方法的多个线程也仍然可能存在共享状态。此时,这个类在调用其 他类的方法时仍然可能需要使用锁。例如,BrokenStatelessObjec.doSomething方法可能需 要改写为:

public String doSomething(String s) ( UnsafeSingleton us = UnsafeSingleton.INSTANCE;
UnsafeStatefullobject sfo = new UnsafeStatefullObject(); String str;
synchronized(this)(
str = sfo.doSomething(s,us.doSomething(s));
}
return str;
}

注意:无状态对象不包含任何实例变量或者可更新静态变量(包括来自相应类的上层类的实例变量或者静态变量)。但是,一个类不包含任何实例变量或者静态变量却不一 定是无状态对象。特殊情况下,不包含任何实例变量或者静态变量的类,其方法实现 时仍然需要借助锁来保障线程安全。

从面向对象编程的角度来看,无状态对象由于不包含任何状态,因此同一个类的多个 无状态对象之间是没有差别的。既然如此,我们又为何要使用对象(无状态对象)而不是 使用一个仅包括静态方法的类呢?这个问题还是得从面向对象编程中的抽象(Abstraction ) 与实现(Implementation)这两个层次来回答。例如,Arrays.sort(T[] a, Comparator? super T> c) 允许我们指定一个Comparator接口实例(c)用于指定数组元素的排序规则。这里的 Comparator接口就是对排序规则的抽象,而sort方法的调用方所传递的具体Comparator 实例则代表实现 个具体的排序规则。显然,我们在调用这个sort方法的时候必须传

递一个Comparator接口实现类的一个实例(对象),而无法传递一个类(尽管类本身在Java 平台中也是一种对象)o

无状态对象可以被多个线程共享,而其客户端代码及其自身的方法实现又无须使用 锁,从而避免了锁可能产生的问题(例如死锁)以及开销。因此,无状态对象有利于提高 并发性。然而,有时候设计出一个纯粹的无状态对象可能有些难度。另外,即便是纯粹的 无状态对象,随着代码的维护,它也可能逐渐演变成其内部实现需要借助锁等线程同步机 制的“非纯粹”的无状态对象:无状态对象的一些方法可能在代码维护过程中需要访问一 些非线程安全对象,而这些对象的访问可能导致这些方法的执行线程存在共享状态。

实践:正确编写Servlet类

无状态对象的一个典型应用就是Java EE中的Servlet0Servlet是一个实现javax.servlet. Servlet接口的托管(Managed)类,而不是一个普通的类。所谓托管类,是指Servlet类 实例的创建、初始化以及销毁的整个对象生命周期完全是由Java Web服务器(例如 Tomcat )控制的,而服务器为每一个Servlet类最多只生成一个实例,该唯一实例会被用 于处理服务器接收到的多个请求。即一个Servlet类的一个(唯一的)实例会被多个线程 共享,并且服务器调用Servlet.service方法时并没有加锁,因此使Servlet实例成为无状态 对象有利于提高服务器的并发性。这也是Servlet类一般不包含实例变量或者静态变量的 原因:一旦Servlet类包含实例变量或者静态变量,我们就需要考虑是否使用锁以保障其 线程安全。例如,清单6-5展示了一个错误(非线程安全)的Servlet类。该Servlet类为 了避免重复创建SimpleDateFormat实例的开销而将SimpleDateFormat实例作为Servlet类 的一个实例变量sdf,然而该Servlet对sdf的访问又没有加锁,从而导致sdf.parse(String)

调用解析出来的日期可能是一个客户端根本没有提交过的错误日期!



/ * 
*该类是一个错误的Servlet类(非线程安全)
*
* @author Viscent Huang
*/
public class UnsafeServlet extends HttpServlet (
private static final long serialVersionUID = -2772996404655982182L; private final SimpleDateFormat sdf = new SimpleDateFormat(nyyyy-MM-ddn);
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, lOException (
String strExpiryDate = req. getParameter (''expirtyDate1');
try (
sdf.parse(strExpiryDate);
} catch (ParseException e) (
e.printStackTrace();
}
//省略其他代码
}
}

以“不变”应万变:不可变对象

不可变对象(Immutable Object)是指一经创建其状态就保持不变的对象。不可变对 象也具有固有的线程安全性,因此不可变对象也可以像无状态对象那样被多个线程共享, 而这些线程访问这些共享对象的时候无须加锁。当不可变对象所建模的现实实体的状态发 生变化时,系统通过创建新的不可变对象实例来进行反映。

一个严格意义上的不可变对象要同时满足以下所有条件。

  • 类本身使用final修饰:这是为了防止通过创建子类来改变其定义的行为。

  • 所有字段都是用final修饰的:使用final修饰不仅仅是从语义上说明被修饰字段 的值不可改变;更重要的是这个语义在多线程环境下保证了被修饰字段的初始化 安全,即final修饰的字段在对其他线程可见时,它必定是初始化完成的。

  • 对象在此初始化过程中没有逸出(Escape ):防止其他类(如该类的内部匿名类)在对象初始化过程中修改其状态。

  • 任何字段,若其引用了其他状态可变的对象(如集合、数组等),则这些字段必 须是private修饰的,并且这些字段值不能对外暴露。若有相关方法要返回这些字 段值,则应该进行防御性复制(Defensive Copy )。

Candidate类就是 一个不可变对象,如下代码片段所示:

public final class Candidate implements Iterable<Endpoint> (
//下游部件节点列表
private final Set<Endpoint> endpoints;
//下游部件节点的总权重
public final int totalweight;
public Candidate(Set<Endpoint> endpoints) {
int sum = 0;
for (Endpoint endpoint : endpoints) {
sum += endpoint.weight;
}
this.totalweight = sum;
this.endpoints = endpoints;
}
@Override
public final Iterator<Endpoint> iterator() (
return ReadOnlylterator.with(endpoints.iterator());
)
//省略其他代码
}

Candidate实例的状态包括下游部件的服务器节点列表(endpoints)以及这些节点的 总权重(totalWeight )o如果下游部件的服务器节点需要变更,例如要增加一个服务器节 点或者有个节点的权重需要调整,那么,我们需要同时更新服务器节点列表以及相应的总 权重。这里所谓的“同时”意味着这个更新操作必须是一个原子操作,否则其他线程可能 看到总权重与服务器节点列表中各个节点的权重总和不一致的情形。如果Candidate类的 状态是可变的,那么为了保障这个操作的原子性,我们往往需要借助锁。而在这个案例中, Candidate是个不可变对象,因此这个更新操作通过创建一个新的Candidate实例并以该实 例为参数调用AbstractLoadBalancer.updateCandidate方法即可实现。 AbstractLoadBalancer类内部会维护一个volatile实例变量candidate来引用Candidate实例, 如下代码片段所示:

public abstract class AbstractLoadBalancer implements LoadBalancer (
private final static Logger LOGGER = Logger.getAnonymousLogger();
//使用volatile变量替代锁(有条件替代)
protected volatile Candidate candidate;
protected final Random random;
//心跳线程
private Thread heartbeatThread;
@Override
public void updateCandidate(final Candidate candidate) (
if (null == candidate |I 0 == candidate.getEndpointCount()) {
throw new IllegalArgumentException("Invalid candidate " + candidate);
}
// 更新 volatile 变量 candidate
this.candidate = candidate;
}
}

这里,candidate实例变量是配置管理线程(负责执行updateCandidate方法)和业务 线程所共享的对象。volatile关键字保障了对实例变量candidate的写操作的原子性,从而 保障整个更新操作(更新下游部件的节点以及总权重)的原子性。另外,volatile关键字 还保障了这种更新的结果对于业务线程的可见性。

从上述例子中可以看出,不可变对象可以使我们在无须借助锁的情况下实现线程安 全,从而避免了锁可能产生的问题以及开销。

有时创建严格意义上的不可变对象比较难,此时不妨考虑使用等效或者近似的不可变 对象,这也同样有利于发挥不可变对象的优势。所谓“等效或者近似”,就是尽可能地满 足不可变对象所需的条件。例如,上述案例中涉及的Endpoint类(参见清单3-11)就是 一个等效不可变对象。

不可变对象的使用能够对垃圾回收效率产生影响,其影响既有消极的也有积极的。由 于基于不可变对象的设计中系统状态的变更是通过创建新的不可变对象实例来实现的,因 此,当系统的状态频繁变更或者不可变对象所占用的内存空间比较大时,不可变对象的不 断创建会增加垃圾回收的负担。但是,不可变对象的使用也可能有利于降低垃圾回收的开 销。这是因为创建不可变对象往往导致堆空间年轻代(Young Generation )中的对象(新 创建的不可变实例)引用年老代(Old Generation )中的对象。而这种对象引用方式,像 比于使用状态可变的对象所导致的年老代对象引用年轻代对象的引用方式,更加有利于减 少垃圾回收的开销:修改一个状态可变对象的实例变量值的时候,如果这个对象已经位于 年老代中,那么在垃圾回收器进行下一轮次要回收(Minor Collection )的时候,年老代中 包含这个对象的卡片(Card,年老代中存储对象的存储单位,一个Card的大小为512字 节)中的所有对象都必须被扫描一遍,以确定年老代中是否有对象对待回收的对象持有引 用。因此,年老代对象持有对年轻代对象的引用会导致次要回收的开销增加。

我们也可以采取某些技术来减少不可变对象(尤其是比较大的不可变对象)所占用的 内存空间。比如,创建不可变对象的时候尽可能让新的不可变对象与老的不可变对象共享 部分内存空间,从而减少内存空间占用。在如清单6-6所示的例子中,BiglmmutableObject 的其中一个构造器允许我们指定一个现有的BiglmmutableObject实例(老的不可变对象) 作为创建新实例的“模板”,该构造器会调用BiglmmutableObject.createReg刷J方法。 BiglmmutableObject.crea/eAegwZAy 方法会对指定的 BiglmmutableObject 实例的 registry 实 例变量进行浅复制(Swallow Copy)得到一个新的HashMap,在对这个新的HashMap中 需要更新的条目进行更新。更新后的HashMap实例会被作为新BiglmmutableObject实例 的registry实例变量的初始值(也是最终值)。由于BiglmmutableObject.creafeRegist以方法 所创建的HashMap实例是老的BiglmmutableObject实例的registry变量的一个浅复制对 象,因此这两个HashMap实例会共用大部分存储空间(主要是HashMap实例所引用的 BigObject所占用的存储空间)□



public final class BiglmmutableObject implements
Iterable<Map.Entry<String, BigObject» (
private final HashMap<String, BigObject> registry;
public BiglmmutableObject(HashMap<Stringr BigObject> registry) (
this.registry = registry;
}
public BiglmmutableObject(BiglmmutableObject prototype, String key,
BigObject newValue) (
this(createRegistry(prototype, key, newValue));
}
@SuppressWarnings ("unchecked**)
private static HashMap<Stringz BigObject> createRegistry (
BiglmmutableObject prototype, String key,
BigObject newValue) (
//从现有对象中复制(浅复制)字段
HashMap<String, BigObject> newRegistry =
(HashMap<String, BigObject>) prototype.registry.clone();
//仅更新需要更新的部分
newRegistry.put(key, newValue);
return newRegistry;
}
@Override
public Iterator<Entry<String, BigObject» iterator() (
//对entryset进行防御性复制
final Set<Entry<String, BigObject» readOnlyEntries = Collections
.unmodifiableSet(registry.entrySet ());
//返回一个只读的Iterator实例
return ReadOnlylterator.with( readOnlyEntries.iterator());
}
public BigObject getObject(String key) (
return registry.get(key);
}
public BiglmmutableObject update(String key,
BigObject newValue) {
return new BiglmmutableObject(this, key, newValue);
) }
class BigObject (
//省略其他代码
)

基于上述原因,当被建模对象的状态变更比较频繁时,不可变对象也不见得就不能使 用。此时,我们需要综合考虑被建模对象的规模、代码目标运行环境的Java虚拟机堆内 存容量、系统对吞吐率和响应性的要求这几个因素。若这几个方面因素综合考虑都能满足 要求,那么使用不可变对象建模也未尝不可。

虽然不可变对象自身的实例变量或者静态变量的值是不可改变的,但是这些变量所引 用的对象本身的状态可能是可变的。例如,清单6-6所示的例子中BiglmmutableObject的 实例变量registry值是不可变的,但是它所引用的HashMap对象的状态是可变的(比如可 已更新其中一个条目)。此时,这些对象所包含的状态如果需要对外暴露的话,那么我们 就需要注意这些对象状态也不能被更改。这通常有两种实现方法。一种是使用迭代器 (Iterator )模式,即让相应的不可变对象实现Iterable接口,然后在该接口定义的iterator 方法中返回一个只读的Iterator实例(它不支持remove方法)。这样,不可变对象的客户 端代码利用Iterator实例,就可以对相应的不可变对象进行遍历操作,而不必关心也不能 更改其内部结构。例如,第3章中的Candidate类(参见清单3-13 )就釆用了这种方法来 阻止客户端代码更新其实例变量endpoints所引用的Set<Endpoint>实例。另外一种方法是 防御性复制(Defensive Copy )o例如,清单6-6中的iterator方法除了使用第一种方法创 建一个只读的Iterator实例,还通过调用Collections.unmodifiableSet方法来对HashMap的 entrySet进行防御性复制。

注意

当被建模现实实体的状态频繁变化的时候,不可变对象也不一定就不能使用。

不可变对象的使用对垃圾回收效率的影响既有消极的一面,也有积极的一面。

希望可以对大家有帮助,喜欢的小伙伴可以关注公众号:小迁不秃头,每天不定时更新~



用户头像

996小迁

关注

我就是我 2020.10.13 加入

5年Java开发经验

评论

发布
暂无评论
面试被问线程安全怎么保障,我的回答让面试官眼前一亮