×

Android面试准备(中高级)

96
胡奚冰
2018.08.15 10:43 字数 13712

Android

Activity生命周期

这里写图片描述

onStart()与onResume()有什么区别?

onStart()是activity界面被显示出来的时候执行的,但不能与它交互;
onResume()是当该activity与用户能进行交互时被执行,用户可以获得activity的焦点,能够与用户交互。

Activity启动流程

startActivity最终都会调用startActivityForResult,通过ActivityManagerProxy调用system_server进程中ActivityManagerService的startActvity方法,如果需要启动的Activity所在进程未启动,则调用Zygote孵化应用进程,进程创建后会调用应用的ActivityThread的main方法,main方法调用attach方法将应用进程绑定到ActivityManagerService(保存应用的ApplicationThread的代理对象)并开启loop循环接收消息。ActivityManagerService通过ApplicationThread的代理发送Message通知启动Activity,ActivityThread内部Handler处理handleLaunchActivity,依次调用performLaunchActivity,handleResumeActivity(即activity的onCreate,onStart,onResume)。
深入理解Activity启动流程

Android类加载器

Android平台上虚拟机运行的是Dex字节码,一种对class文件优化的产物,传统Class文件是一个Java源码文件会生成一个.class文件,而Android是把所有Class文件进行合并,优化,然后生成一个最终的class.dex,目的是把不同class文件重复的东西只需保留一份,如果我们的Android应用不进行分dex处理,最后一个应用的apk只会有一个dex文件。
Android中常用的有两种类加载器,DexClassLoader和PathClassLoader,它们都继承于BaseDexClassLoader。区别在于调用父类构造器时,DexClassLoader多传了一个optimizedDirectory参数,这个目录必须是内部存储路径,用来缓存系统创建的Dex文件。而PathClassLoader该参数为null,只能加载内部存储目录的Dex文件。所以我们可以用DexClassLoader去加载外部的apk。

Android消息机制

  1. 应用启动是从ActivityThread的main开始的,先是执行了Looper.prepare(),该方法先是new了一个Looper对象,在私有的构造方法中又创建了MessageQueue作为此Looper对象的成员变量,Looper对象通过ThreadLocal绑定MainThread中;
  2. 当我们创建Handler子类对象时,在构造方法中通过ThreadLocal获取绑定的Looper对象,并获取此Looper对象的成员变量MessageQueue作为该Handler对象的成员变量;
  3. 在子线程中调用上一步创建的Handler子类对象的sendMesage(msg)方法时,在该方法中将msg的target属性设置为自己本身,同时调用成员变量MessageQueue对象的enqueueMessag()方法将msg放入MessageQueue中;
  4. 主线程创建好之后,会执行Looper.loop()方法,该方法中获取与线程绑定的Looper对象,继而获取该Looper对象的成员变量MessageQueue对象,并开启一个会阻塞(不占用资源)的死循环,只要MessageQueue中有msg,就会获取该msg,并执行msg.target.dispatchMessage(msg)方法(msg.target即上一步引用的handler对象),此方法中调用了我们第二步创建handler子类对象时覆写的handleMessage()方法,之后将该msg对象存入回收池;

Looper.loop()为什么不会阻塞主线程

Android是基于事件驱动的,即所有Activity的生命周期都是通过Handler事件驱动的。loop方法中会调用MessageQueue的next方法获取下一个message,当没有消息时,基于Linux pipe/epoll机制会阻塞在loop的queue.next()中的nativePollOnce()方法里,并不会消耗CPU。

IdleHandler (闲时机制)

IdleHandler是一个回调接口,可以通过MessageQueue的addIdleHandler添加实现类。当MessageQueue中的任务暂时处理完了(没有新任务或者下一个任务延时在之后),这个时候会回调这个接口,返回false,那么就会移除它,返回true就会在下次message处理完了的时候继续回调。

同步屏障机制(sync barrier)

同步屏障可以通过MessageQueue.postSyncBarrier函数来设置。该方法发送了一个没有target的Message到Queue中,在next方法中获取消息时,如果发现没有target的Message,则在一定的时间内跳过同步消息,优先执行异步消息。再换句话说,同步屏障为Handler消息机制增加了一种简单的优先级机制,异步消息的优先级要高于同步消息。在创建Handler时有一个async参数,传true表示此handler发送的时异步消息。ViewRootImpl.scheduleTraversals方法就使用了同步屏障,保证UI绘制优先执行。

View的绘制原理

View的绘制从ActivityThread类中Handler的处理RESUME_ACTIVITY事件开始,在执行performResumeActivity之后,创建Window以及DecorView并调用WindowManager的addView方法添加到屏幕上,addView又调用ViewRootImpl的setView方法,最终执行performTraversals方法,依次执行performMeasure,performLayout,performDraw。也就是view绘制的三大过程。
measure过程测量view的视图大小,最终需要调用setMeasuredDimension方法设置测量的结果,如果是ViewGroup需要调用measureChildren或者measureChild方法进而计算自己的大小。
layout过程是摆放view的过程,View不需要实现,通常由ViewGroup实现,在实现onLayout时可以通过getMeasuredWidth等方法获取measure过程测量的结果进行摆放。
draw过程先是绘制背景,其次调用onDraw()方法绘制view的内容,再然后调用dispatchDraw()调用子view的draw方法,最后绘制滚动条。ViewGroup默认不会执行onDraw方法,如果复写了onDraw(Canvas)方法,需要调用 setWillNotDraw(false);清楚不需要绘制的标记。
Android视图绘制流程完全解析,带你一步步深入了解View(二)

什么是MeasureSpec

MeasureSpec代表一个32位int值,高两位代表SpecMode(测量模式),低30位代表SpecSize(具体大小)。
SpecMode有三类:

  • UNSPECIFIED 表示父容器不对View有任何限制,一般用于系统内部,表示一种测量状态;
  • EXACTLY 父容器已经检测出view所需的精确大小,这时候view的最终大小SpecSize所指定的值,相当于match_parent或指定具体数值。
  • AT_MOST 父容器指定一个可用大小即SpecSize,view的大小不能大于这个值,具体多大要看view的具体实现,相当于wrap_content。

getWidth()方法和getMeasureWidth()区别呢?

首先getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。另外,getMeasureWidth()方法中的值是通过setMeasuredDimension()方法来进行设置的,而getWidth()方法中的值则是通过视图右边的坐标减去左边的坐标计算出来的。

事件分发机制

图解 Android 事件分发机制

requestLayout,invalidate,postInvalidate区别与联系

相同点:三个方法都有刷新界面的效果。
不同点:invalidate和postInvalidate只会调用onDraw()方法;requestLayout则会重新调用onMeasure、onLayout、onDraw。

调用了invalidate方法后,会为该View添加一个标记位,同时不断向父容器请求刷新,父容器通过计算得出自身需要重绘的区域,直到传递到ViewRootImpl中,最终触发performTraversals方法,进行开始View树重绘流程(只绘制需要重绘的视图)。
调用requestLayout方法,会标记当前View及父容器,同时逐层向上提交,直到ViewRootImpl处理该事件,ViewRootImpl会调用三大流程,从measure开始,对于每一个含有标记位的view及其子View都会进行测量onMeasure、布局onLayout、绘制onDraw。
Android View 深度分析requestLayout、invalidate与postInvalidate

Binder机制,共享内存实现原理

为什么使用Binder?

v2-30dce36be4e6617596b5fab96ef904c6_hd.jpg

概念
进程隔离
进程空间划分:用户空间(User Space)/内核空间(Kernel Space)
系统调用:用户态与内核态

原理
跨进程通信是需要内核空间做支持的。传统的 IPC 机制如管道、Socket 都是内核的一部分,因此通过内核支持来实现进程间通信自然是没问题的。但是 Binder 并不是 Linux 系统内核的一部分,那怎么办呢?这就得益于 Linux 的动态内核可加载模块(Loadable Kernel Module,LKM)的机制;模块是具有独立功能的程序,它可以被单独编译,但是不能独立运行。它在运行时被链接到内核作为内核的一部分运行。这样,Android 系统就可以通过动态添加一个内核模块运行在内核空间,用户进程之间通过这个内核模块作为桥梁来实现通信。

在 Android 系统中,这个运行在内核空间,负责各个用户进程通过 Binder 实现通信的内核模块就叫 Binder 驱动(Binder Dirver)。

那么在 Android 系统中用户进程之间是如何通过这个内核模块(Binder 驱动)来实现通信的呢?难道是和前面说的传统 IPC 机制一样,先将数据从发送方进程拷贝到内核缓存区,然后再将数据从内核缓存区拷贝到接收方进程,通过两次拷贝来实现吗?显然不是,否则也不会有开篇所说的 Binder 在性能方面的优势了。

这就不得不通道 Linux 下的另一个概念:内存映射

Binder IPC 机制中涉及到的内存映射通过 mmap() 来实现,mmap() 是操作系统中一种内存映射的方法。内存映射简单的讲就是将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间;反之内核空间对这段区域的修改也能直接反应到用户空间。
一次完整的 Binder IPC 通信过程通常是这样:

  1. 首先 Binder 驱动在内核空间创建一个数据接收缓存区;
  2. 接着在内核空间开辟一块内核缓存区,建立内核缓存区和内核中数据接收缓存区之间的映射关系,以及内核中数据接收缓存区和接收进程用户空间地址的映射关系;
  3. 发送方进程通过系统调用 copyfromuser() 将数据 copy 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信。

Binder通讯模型
Binder是基于C/S架构的,其中定义了4个角色:Client、Server、Binder驱动和ServiceManager。

  • Binder驱动:类似网络通信中的路由器,负责将Client的请求转发到具体的Server中执行,并将Server返回的数据传回给Client。
  • ServiceManager:类似网络通信中的DNS服务器,负责将Client请求的Binder描述符转化为具体的Server地址,以便Binder驱动能够转发给具体的Server。Server如需提供Binder服务,需要向ServiceManager注册。
    具体的通讯过程
  1. Server向ServiceManager注册。Server通过Binder驱动向ServiceManager注册,声明可以对外提供服务。ServiceManager中会保留一份映射表。
  2. Client向ServiceManager请求Server的Binder引用。Client想要请求Server的数据时,需要先通过Binder驱动向ServiceManager请求Server的Binder引用(代理对象)。
  3. 向具体的Server发送请求。Client拿到这个Binder代理对象后,就可以通过Binder驱动和Server进行通信了。
  4. Server返回结果。Server响应请求后,需要再次通过Binder驱动将结果返回给Client。

ServiceManager是一个单独的进程,那么Server与ServiceManager通讯是靠什么呢?
当Android系统启动后,会创建一个名称为servicemanager的进程,这个进程通过一个约定的命令BINDERSETCONTEXT_MGR向Binder驱动注册,申请成为为ServiceManager,Binder驱动会自动为ServiceManager创建一个Binder实体。并且这个Binder实体的引用在所有的Client中都为0,也就说各个Client通过这个0号引用就可以和ServiceManager进行通信。Server通过0号引用向ServiceManager进行注册,Client通过0号引用就可以获取到要通信的Server的Binder引用。
写给 Android 应用工程师的 Binder 原理剖析
一篇文章了解相见恨晚的 Android Binder 进程间通讯机制

序列化的方式

Serializable是Java提供的一个序列化接口,是一个空接口,用于标示对象是否可以支持序列化,通过ObjectOutputStrean及ObjectInputStream实现序列化和反序列化的过程。注意可以为需要序列化的对象设置一个serialVersionUID,在反序列化的时候系统会检测文件中的serialVersionUID是否与当前类的值一致,如果不一致则说明类发生了修改,反序列化失败。因此对于可能会修改的类最好指定serialVersionUID的值。
Parcelable是Android特有的一个实现序列化的接口,在Parcel内部包装了可序列化的数据,可以在Binder中自由传输。序列化的功能由writeToParcel方法来完成,最终通过Parcel的一系列write方法完成。反序列化功能由CREAOR来完成,其内部标明了如何创建序列化对象和数组,并通过Parcel的一系列read方法来完成反序列化的过程。

Fragment的懒加载实现

Fragment可见状态改变时会被调用setUserVisibleHint()方法,可以通过复写该方法实现Fragment的懒加载,但需要注意该方法可能在onVIewCreated之前调用,需要确保界面已经初始化完成的情况下再去加载数据,避免空指针。
Fragment的懒加载

RecyclerView与ListView(缓存原理,区别联系,优缺点)

缓存区别:

  1. 层级不同:
    ListView有两级缓存,在屏幕与非屏幕内。
    RecyclerView比ListView多两级缓存,支持多个离屏ItemView缓存(匹配pos获取目标位置的缓存,如果匹配则无需再次bindView),支持开发者自定义缓存处理逻辑,支持所有RecyclerView共用同一个RecyclerViewPool(缓存池)。
  2. 缓存不同:
    ListView缓存View。
    RecyclerView缓存RecyclerView.ViewHolder,抽象可理解为:
    View + ViewHolder(避免每次createView时调用findViewById) + flag(标识状态);

优点
RecylerView提供了局部刷新的接口,通过局部刷新,就能避免调用许多无用的bindView。
RecyclerView的扩展性更强大(LayoutManager、ItemDecoration等)。

Android两种虚拟机区别与联系

Android中的Dalvik虚拟机相较于Java虚拟机针对手机的特点做了很多优化。
Dalvik基于寄存器,而JVM基于栈。在基于寄存器的虚拟机里,可以更为有效的减少冗余指令的分发和减少内存的读写访问。
Dalvik经过优化,允许在有限的内存中同时运行多个虚拟机的实例,并且每一个 Dalvik应用作为一个独立的Linux进程执行。
java虚拟机运行的是java字节码。(java类会被编译成一个或多个字节码.class文件,打包到.jar文件中,java虚拟机从相应的.class文件和.jar文件中获取相应的字节码)
Dalvik运行的是自定义的.dex字节码格式。(java类被编译成.class文件后,会通过一个dx工具将所有的.class文件转换成一个.dex文件,然后dalvik虚拟机会从其中读取指令和数据)
Android开发之浅谈java虚拟机和Dalvik虚拟机的区别

adb常用命令行

查看当前连接的设备:adb devices
安装应用:adb install -r <apk_path> -r表示覆盖安装
卸载apk:adb uninstall <packagename>

ADB 用法大全

apk打包流程

  1. aapt工具打包资源文件,生成R.java文件
  2. aidl工具处理AIDL文件,生成对应的.java文件
  3. javac工具编译Java文件,生成对应的.class文件
  4. 把.class文件转化成Davik VM支持的.dex文件
  5. apkbuilder工具打包生成未签名的.apk文件
  6. jarsigner对未签名.apk文件进行签名
  7. zipalign工具对签名后的.apk文件进行对齐处理

Android应用程序(APK)的编译打包过程

apk安装流程

  1. 复制APK到/data/app目录下,解压并扫描安装包。
  2. 资源管理器解析APK里的资源文件。
  3. 解析AndroidManifest文件,并在/data/data/目录下创建对应的应用数据目录。
  4. 然后对dex文件进行优化,并保存在dalvik-cache目录下。
  5. 将AndroidManifest文件解析出的四大组件信息注册到PackageManagerService中。
  6. 安装完成后,发送广播。

apk瘦身

APK主要由以下几部分组成:

  • META-INF/ :包含了签名文件CERT.SF、CERT.RSA,以及 manifest 文件MANIFEST.MF。
  • assets/ : 存放资源文件,这些资源不会被编译成二进制。
  • lib/ :包含了一些引用的第三方库。
  • resources.arsc :包含res/values/中所有资源,例如strings,styles,以及其他未被包含在resources.arsc中的资源路径信息,例如layout 文件、图片等。
  • res/ :包含res中没有被存放到resources.arsc的资源。
  • classes.dex :经过dx编译能被android虚拟机理解的Java源码文件。
  • AndroidManifest.xml :清单文件

其中占据较大内存的是res资源、lib、class.dex,因此我们可以从下面的几个方面下手:

  1. 代码方面可以通过代码混淆,这个一般都会去做。平时也可以删除一些没有使用类。
  2. 去除无用资源。使用lint工具来检测没有使用到的资源,或者在gradle中配置shrinkResources来删除包括库中所有的无用的资源,需要配合proguard压缩代码使用。这里需要注意项目中是否存在使用getIdentifier方式获取资源,这种方式类似反射lint及shrinkResources无法检测情况。如果存在这种方式,则需要配置一个keep.xml来记录使用反射获取的资源。压缩代码和资源
  3. 去除无用国际化支持。对于一些第三库来说(如support),因为国际化的问题,它们可能会支持了几十种语言,但我们的应用可能只需要支持几种语言,可以通过配置resConfigs提出不要的语言支持。
  4. 不同尺寸的图片支持。通常情况下只需要一套xxhpi的图片就可以支持大部分分辨率的要求了,因此,我们只需要保留一套图片。
  5. 图片压缩。 png压缩或者使用webP图片,完美支持需要Android版本4.2.1+
  6. 使用矢量图形。简单的图标可以使用矢量图片。

HTTP缓存机制

图片来自上述链接

缓存的响应头:


20171103144205821.png

Cache-control:标明缓存的最大存活时常;
Date:服务器告诉客户端,该资源的发送时间;
Expires:表示过期时间(该字段是1.0的东西,当cache-control和该字段同时存在的条件下,cache-control的优先级更高);
Last-Modified:服务器告诉客户端,资源的最后修改时间;
还有一个字段,这个图没给出,就是E-Tag:当前资源在服务器的唯一标识,可用于判断资源的内容是否被修改了。
除以上响应头字段以外,还需了解两个相关的Request请求头:If-Modified-since、If-none-Match。这两个字段是和Last-Modified、E-Tag配合使用的。大致流程如下:
服务器收到请求时,会在200 OK中回送该资源的Last-Modified和ETag头(服务器支持缓存的情况下才会有这两个头哦),客户端将该资源保存在cache中,并记录这两个属性。当客户端需要发送相同的请求时,根据Date + Cache-control来判断是否缓存过期,如果过期了,会在请求中携带If-Modified-Since和If-None-Match两个头。两个头的值分别是响应中Last-Modified和ETag头的值。服务器通过这两个头判断本地资源未发生变化,客户端不需要重新下载,返回304响应。

组件化

  • 在gradle.properties声明一个变量用于控制是否是调试模式,并在dependencies中根据是否是调试模式依赖必要组件。
  • 通过resourcePrefix规范module中资源的命名前缀。
  • 组件间通过ARouter完成界面跳转和功能调用。

MVP

三方库

okhttp原理

OkHttpClient通过newCall可以将一个Request构建成一个Call,Call表示准备被执行的请求。Call调用executed或enqueue会调用Dispatcher对应的方法在当前线程或者一步开始执行请求,经过RealInterceptorChain获得最终结果,RealInterceptorChain是一个拦截器链,其中依次包含以下拦截器:

  • 自定义的拦截器
  • retryAndFollowUpInterceptor 请求失败重试
  • BridgeInterceptor 为请求添加请求头,为响应添加响应头
  • CacheInterceptor 缓存get请求
  • ConnectInterceptor 连接相关的拦截器,分配一个Connection和HttpCodec为最终的请求做准备
  • CallServerInterceptor 该拦截器就是利用HttpCodec完成最终请求的发送

okhttp源码解析

Retrofit的实现与原理

Retrofit采用动态代理,创建声明service接口的实现对象。当我们调用service的方法时候会执行InvocationHandler的invoke方法。在这方法中:首先,通过method把它转换成ServiceMethod,该类是对声明方法的解析,可以进一步将设定参数变成Request ;然后,通过serviceMethod, args获取到okHttpCall 对象,实际调用okhttp的网络请求方法就在该类中,并且会使用serviceMethod中的responseConverter对ResponseBody转化;最后,再把okHttpCall进一步封装成声明的返回对象(默认是ExecutorCallbackCall,将原本call的回调转发至UI线程)。

Retrofit2使用详解及从源码中解析原理
Retrofit2 完全解析 探索与okhttp之间的关系

ARouter原理

可能是最详细的ARouter源码分析

RxLifecycle原理

在Activity中,定义一个Observable(Subject),在不同的生命周期发射不同的事件;
通过compose操作符(内部实际上还是依赖takeUntil操作符),定义了上游数据,当其接收到Subject的特定事件时,取消订阅;
Subject的特定事件并非是ActivityEvent,而是简单的boolean,它已经内部通过combineLast操作符进行了对应的转化。

RxJava

Java

类的加载机制

程序在启动的时候,并不会一次性加载程序所要用的所有class文件,而是根据程序的需要,通过Java的类加载机制(ClassLoader)来动态加载某个class文件到内存当中的,从而只有class文件被载入到了内存之后,才能被其它class所引用。所以ClassLoader就是用来动态加载class文件到内存当中用的。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中准备、验证、解析3个部分统称为连接(Linking)。

  • 加载:查找和导入Class文件;
  • 链接:把类的二进制数据合并到JRE中;
    (a) 验证:检查载入Class文件数据的正确性;
    (b) 准备:给类的静态变量分配存储空间;
    (c) 解析:将符号引用转成直接引用;
  • 初始化:对类的静态变量,静态代码块执行初始化操作

什么时候发生类初始化

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例左后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄锁对应的类没有进行过初始化时。

双亲委派模型

Java中存在3种类加载器:
(1) Bootstrap ClassLoader : 将存放于<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用 。
(2) Extension ClassLoader : 将<JAVA_HOME>\lib\ext目录下的,或者被java.ext.dirs系统变量所指定的路径中的所有类库加载。开发者可以直接使用扩展类加载器。
(3) Application ClassLoader : 负责加载用户类路径(ClassPath)上所指定的类库,开发者可直接使用。
每个ClassLoader实例都有一个父类加载器的引用(不是继承关系,是一个包含的关系),虚拟机内置的类加载器(Bootstrap ClassLoader)本身没有父类加载器,但是可以用做其他ClassLoader实例的父类加载器。
当一个ClassLoader 实例需要加载某个类时,它会试图在亲自搜索这个类之前先把这个任务委托给它的父类加载器,这个过程是由上而下依次检查的,首先由顶层的类加载器Bootstrap ClassLoader进行加载,如果没有加载到,则把任务转交给Extension ClassLoader加载,如果也没有找到,则转交给AppClassLoader进行加载,还是没有的话,则交给委托的发起者,由它到指定的文件系统或者网络等URL中进行加载类。还没有找到的话,则会抛出CLassNotFoundException异常。否则将这个类生成一个类的定义,并将它加载到内存中,最后返回这个类在内存中的Class实例对象。

为什么使用双亲委托模型

JVM在判断两个class是否相同时,不仅要判断两个类名是否相同,还要判断是否是同一个类加载器加载的。

  1. 避免重复加载,父类已经加载了,则子CLassLoader没有必要再次加载。
  2. 考虑安全因素,假设自定义一个String类,除非改变JDK中CLassLoader的搜索类的默认算法,否则用户自定义的CLassLoader如法加载一个自己写的String类,因为String类在启动时就被引导类加载器Bootstrap CLassLoader加载了。

HashMap原理,Hash冲突

在JDK1.6,JDK1.7中,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个链表中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。
当链表数组的容量超过初始容量*加载因子(默认0.75)时,再散列将链表数组扩大2倍,把原链表数组的搬移到新的数组中。为什么需要使用加载因子?为什么需要扩容呢?因为如果填充比很大,说明利用的空间很多,如果一直不进行扩容的话,链表就会越来越长,这样查找的效率很低,扩容之后,将原来链表数组的每一个链表分成奇偶两个子链表分别挂在新链表数组的散列位置,这样就减少了每个链表的长度,增加查找效率。
HashMap是非线程安全的,HashTable、ConcurrentHashMap是线程安全的。
HashMap的键和值都允许有null存在,而HashTable、ConcurrentHashMap则都不行。
因为线程安全、哈希效率的问题,HashMap效率比HashTable、ConcurrentHashMap的都要高。
HashTable里使用的是synchronized关键字,这其实是对对象加锁,锁住的都是对象整体,当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。
ConcurrentHashMap引入了分割(Segment),可以理解为把一个大的Map拆分成N个小的HashTable,在put方法中,会根据hash(paramK.hashCode())来决定具体存放进哪个Segment,如果查看Segment的put操作,我们会发现内部使用的同步机制是基于lock操作的,这样就可以对Map的一部分(Segment)进行上锁,这样影响的只是将要放入同一个Segment的元素的put操作,保证同步的时候,锁住的不是整个Map(HashTable就是这么做的),相对于HashTable提高了多线程环境下的性能,因此HashTable已经被淘汰了。

Java中HashMap底层实现原理(JDK1.8)源码分析

什么是Fail-Fast机制

Fail-Fast是Java集合的一种错误检测机制。当遍历集合的同时修改集合或者多个线程对集合进行结构上的改变的操作时,有可能会产生fail-fast机制,记住是有可能,而不是一定。其实就是抛出ConcurrentModificationException 异常。
集合的迭代器在调用next()、remove()方法时都会调用checkForComodification()方法,该方法主要就是检测modCount == expectedModCount ? 若不等则抛出ConcurrentModificationException 异常,从而产生fail-fast机制。modCount是在每次改变集合数量时会改变的值。

Java提高篇(三四)-----fail-fast机制

Java泛型

Java泛型详解

Java多线程中调用wait() 和 sleep()方法有什么不同?

Java程序中wait 和 sleep都会造成某种形式的暂停,它们可以满足不同的需要。wait()方法用于线程间通信,如果等待条件为真且其它线程被唤醒时它会释放锁,而 sleep()方法仅仅释放CPU资源或者让当前线程停止执行一段时间,但不会释放锁。

volatile的作用和原理

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行。
volatile是轻量级的synchronized(volatile不会引起线程上下文的切换和调度),它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
由于内存访问速度远不及CPU处理速度,为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后在进行操作,但操作完不知道何时会写到内存。普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。如果对声明了volatile的变量进行写操作,JVM就会想处理器发送一条Lock前缀的指令,表示将当前处理器缓存行的数据写回到系统内存。

一个int变量,用volatile修饰,多线程去操作++,线程安全吗?

不安全。volatile只能保证可见性,并不能保证原子性。i++实际上会被分成多步完成:1)获取i的值;2)执行i+1;3)将结果赋值给i。volatile只能保证这3步不被重排序,多线程情况下,可能两个线程同时获取i,执行i+1,然后都赋值结果2,实际上应该进行两次+1操作。

那如何才能保证i++线程安全?

可以使用java.util.concurrent.atomic包下的原子类,如AtomicInteger。
其实现原理是采用CAS自旋操作更新值。CAS即compare and swap的缩写,中文翻译成比较并交换。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。自旋就是不断尝试CAS操作直到成功为止。

CAS实现原子操作会出现什么问题?

  • ABA问题。因为CAS需要在操作之的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成,有变成A,那么使用CAS进行检查时会发现它的值没有发生变化,但实际上发生了变化。ABA问题可以通过添加版本号来解决。Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
  • 循环时间长开销大。pause指令优化。
  • 只能保证一个共享变量的原子操作。可以合并成一个对象进行CAS操作。

synchronized

Java中每个对象都可以作为锁:

  • 对于普通同步方法,锁是当前实例对象;
  • 对于静态同步方法,锁是当前类的Class对象;
  • 对于同步方法块,锁是括号中配置的对象;

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。synchronized用的锁是存在Java对象头里的MarkWord,通常是32bit或者64bit,其中最后2bit表示锁标志位

java对象结构

Java SE1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,在1.6中锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几种状态会随着竞争情况逐渐升级。锁可以升级但不能降级。

偏向锁

偏向锁获取过程:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
  5. 执行同步代码。

轻量级锁

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
  2. 拷贝对象头中的Mark Word复制到锁记录中;
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
    自旋
    如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
    但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。
    如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

线程池

好处:1)降低资源消耗;2)提高相应速度;3)提高线程的可管理性。
线程池的实现原理:

  • 当提交一个新任务到线程池时,判断核心线程池里的线程是否都在执行。如果不是,则创建一个新的线程执行任务。如果核心线程池的线程都在执行任务,则进入下个流程。
  • 判断工作队列是否已满。如果未满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  • 判断线程池是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果满了,则交给饱和策略来处理这个任务。

假如有n个网络线程,你需要当n个网络线程完成之后,再去做数据处理,你会怎么解决?

这题考的其实是多线程同步的问题。这种情况可以可以使用thread.join();join方法会阻塞直到thread线程终止才返回。更复杂一点的情况也可以使用CountDownLatch,CountDownLatch的构造接收一个int参数作为计数器,每次调用countDown方法计数器减一。做数据处理的线程调用await方法阻塞直到计数器为0时。

Java中interrupted 和 isInterruptedd方法的区别?

interrupted() 和 isInterrupted()的主要区别是前者会将中断状态清除而后者不会。Java多线程的中断机制是用内部标识来实现的,调用Thread.interrupt()来中断一个线程就会设置中断标识为true。当中断线程调用静态方法Thread.interrupted()来 检查中断状态时,中断状态会被清零。而非静态方法isInterrupted()用来查询其它线程的中断状态且不会改变中断状态标识。简单的说就是任何抛 出InterruptedException异常的方法都会将中断状态清零。无论如何,一个线程的中断状态有有可能被其它线程调用中断来改变。

懒汉式单例的同步问题

同步的懒加载虽然是线程安全的,但是导致性能开销。因此产生了双重检查锁定。但双重检查锁定存在隐藏的问题。instance = new Instance()实际上会分为三步操作:1)分配对象的内存空间;2)初始化对象;3)设置instance指向刚分配的内存地址;由于指令重排序,2和3的顺序并不确定。在多线程的情况下,第一个线程执行了1,3,此时第二个线程判断instance不为null,但实际上操作2还没有执行,第二个线程就会获得一个还未初始化的对象,直接使用就会造成空指针。
解决方案是用volatile修饰instance,在JDK 1.5加强了volatile的语意之后,用volatile修饰instance就阻止了2和3的重排序,进而避免上述情况的发生。
另一种方式则是使用静态内部类:

public class Singleton {
    private static class InstanceHolder {
        public static Singleton instance = new Singleton();
    }

    public static Singleton getInstance() {
        return InstanceHolder.instance;
    }
}

其原理是利用类初始化时会加上初始化锁确保类对象的唯一性。

什么是ThreadLocal

ThreadLocal即线程变量,它为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。从线程的角度看,目标变量就象是线程的本地变量,这也是类名中“Local”所要表达的意思。ThreadLocal的实现是以ThreadLocal对象为键。任意对象为值得存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

什么是数据竞争

数据竞争的定义:在一个线程写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。

Java内存模型(Java Memory Model JMM)

JM屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是一个抽象概念,它涵盖了缓存、写缓存区、寄存器以及其他的硬件和编译器优化。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。在多线程中重排序会对程序的执行结果有影响。
JSR-133内存模型采用happens-before的概念来阐述操作之间的内存可见性。happens-before会限制重排序以满足规则。
主要的happens-before规则有如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before与锁随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before与任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

Java内存区域

  • 程序计数器:当前线程锁执行的字节码的行号指示器,用于线程切换恢复,是线程私有的;
  • Java虚拟机栈(栈):虚拟机栈也是线程私有的。每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 本地方法栈:与虚拟机栈类似,服务于Native方法。
  • Java堆:堆是被所有线程共享的一块内存,用于存放对象实例。是垃圾收集器管理的主要区域,也被称作GC堆。
  • 方法区:与Java堆一样,是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态常量、即时编译器编译后的代码等数据。
  • 运行时常量池:是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。

判断对象是否需要回收的方法

  • 引用计数算法。实现简单,判定效率高,但不能解决循环引用问题,同时计数器的增加和减少带来额外开销,JDK1.1以后废弃了。
  • 可达性分析算法/根搜索算法 。根搜索算法是通过一些“GC Roots”对象作为起点,从这些节点开始往下搜索,搜索通过的路径成为引用链(Reference Chain),当一个对象没有被GC Roots 的引用链连接的时候,说明这个对象是不可用的。 Java中可作为“GC Root”的对象包括:虚拟机栈(本地变量表)中引用的对象;方法区中类静态属性和常量引用的对象。本地方法栈中引用的对象。

引用类型

  • 强引用:默认的引用方式,不会被垃圾回收,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。
  • 软引用(SoftReference):如果一个对象只被软引用指向,只有内存空间不足够时,垃圾回收器才会回收它;
  • 弱引用(WeakReference):如果一个对象只被弱引用指向,当JVM进行垃圾回收时,无论内存是否充足,都会回收该对象。
  • 虚引用(PhantomReference):虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用通常和ReferenceQueue配合使用。
    ReferenceQueue
    作为一个Java对象,Reference对象除了具有保存引用的特殊性之外,也具有Java对象的一般性。所以,当对象被回收之后,虽然这个Reference对象的get()方法返回null,但这个SoftReference对象已经不再具有存在的价值,需要一个适当的清除机制,避免大量Reference对象带来的内存泄漏。
    在java.lang.ref包里还提供了ReferenceQueue。我们创建Reference对象时使用两个参数的构造传入ReferenceQueue,当Reference所引用的对象被垃圾收集器回收的同时,Reference对象被列入ReferenceQueue。也就是说,ReferenceQueue中保存的对象是Reference对象,而且是已经失去了它所软引用的对象的Reference对象。另外从ReferenceQueue这个名字也可以看出,它是一个队列,当我们调用它的poll()方法的时候,如果这个队列中不是空队列,那么将返回队列前面的那个Reference对象。于是我们可以在适当的时候把这些失去所软引用的对象的SoftReference对象清除掉。

垃圾收集算法

  1. 标记-清楚算法(Mark-Sweep)
    在标记阶段,确定所有要回收的对象,并做标记。清除阶段紧随标记阶段,将标记阶段确定不可用的对象清除。标记—清除算法是基础的收集算法,有两个不足:1)标记和清除阶段的效率不高;2)清除后回产生大量的不连续空间,这样当程序需要分配大内存对象时,可能无法找到足够的连续空间。
  2. 复制算法(Copying)
    复制算法是把内存分成大小相等的两块,每次使用其中一块,当垃圾回收的时候,把存活的对象复制到另一块上,然后把这块内存整个清理掉。复制算法实现简单,运行效率高,但是由于每次只能使用其中的一半,造成内存的利用率不高。现在的JVM 用复制方法收集新生代,由于新生代中大部分对象(98%)都是朝生夕死的,所以会分成1块大内存Eden和两块小内存Survivor(大概是8:1:1),每次使用1块大内存和1块小内存,当回收时将2块内存中存活的对象赋值到另一块小内存中,然后清理剩下的。
  3. 标记—整理算法(Mark-Compact)
    标记—整理算法和复制算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存。标记—整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。
  4. 分代收集(Generational Collection)
    分代收集是根据对象的存活时间把内存分为新生代和老年代,根据各代对象的存活特点,每个代采用不同的垃圾回收算法。新生代采用复制算法,老年代采用标记—整理算法。

内存分配策略

  • 对象优先在Eden分配。
  • 大对象直接进入老年代。 大对象是指需要大量连续内存空间的Java对象,最典型的就是那种很长的字符串以及数组。
  • 长期存活的对象进入老年代。存活过一次新生代的GC,Age+1,当达到一定程度(默认15)进入老年代。
  • 动态对象年龄判定。如果在Survivor空间中相同Age所有对象大小的总和大于Survivor空间一半。那么Age大于等于该Age的对象就可以直接进入老年代。
  • 空间分配担保。 在发生新生代GC之前,会检查老年代的剩余空间是否大于新生代所有对象的总和。如果大于则是安全的,如果不大于有风险。
Android
Web note ad 1