讲讲Java面向对象
Java面向对象其实就是把现实世界里的事物抽象成类和对象,通过这种方式组织代码,让程序更符合逻辑,也更容易维护和扩展。比如说,我们在Java中会把一些共同属性和行为抽象成一个类,然后根据具体需要生成对象。对象就是类的一个实例,里面的数据对应于现实的状态,而方法描述了它能做的事。
首先,封装是Java面向对象的重要概念。它就是把类的内部状态隐藏起来,只通过公开的方法来改变或者获取数据,这样可以保证数据的安全性和一致性。我会说这是我们在设计类时必须考虑的部分,特别是在多人协作或者后期维护的时候,通过限制外部对内部数据的直接操作,可以有效防止错误和意外的修改。
然后就是继承,它允许我们定义一个父类,把一些公共属性和行为抽取出来,然后让多个子类去继承和扩展。这样可以减少代码的重复,使得代码更加模块化。举个例子,如果有一个“动物”类,里面可能包含基本行为,比如进食、移动,然后再设计猫、狗之类的子类,继承这些公共行为,同时根据各自的特点去重写或者增加独有的功能。这样就实现了代码的复用和扩展。
多态也是Java面向对象的灵魂所在。多态允许一个父类引用指向不同的子类对象,然后在调用方法时,根据实际对象去执行对应的版本。这种机制,让我们能够写出更加灵活、抽象的代码。比如在设计时,我们可以只关心一个方法的调用,而不用关心具体的实现细节,这样如果后续要增加新的子类,只要符合父类接口,就可以无缝接入。
最后,抽象不单单是抽象一个类,更重要的是提炼出问题的本质。在Java中,我们可以通过抽象类和接口来实现设计的抽象,让代码从具体细节中解脱出来,关注于核心业务逻辑。接口的使用还增强了代码的灵活性,方便以后拓展多种实现方式。
总体来说,Java的面向对象模型就是围绕类和对象构建,强调数据和方法的封装,继承、复用和多态性的实现,以及用抽象来解耦具体实现。
抽象类和接口有什么区别
首先从抽象类来说,抽象类可以包含抽象方法,也可以包含具体的方法。就是说,抽象类里面可以写一些现成的实现逻辑,供子类直接使用,也可以让子类去实现自己的逻辑。相当于抽象类更像是一个半成品的东西,你给出了一部分公共逻辑,让子类去完善。它适合用在有共同属性和行为,但又希望保留一定灵活性的场景。
而接口更像是一种完全的约定,在接口里,你主要是定义方法的签名,告诉外部这个类需要做哪些事情,把实现细节留给具体的实现类。接口强调的是规范和能力,特别适用于不相关的类共同行为的约定。比如说,一个类可能既需要实现序列化功能,又可能实现另一个业务接口,接口能够支持多继承,也就是说一个类可以实现多个接口,这在Java里是非常重要的。
再一个区别是,抽象类可以有成员变量和构造方法,有状态;而接口在以前都是没有状态的(虽然Java 8之后接口可以有默认方法和静态方法,但核心还是要求实现类自己去维护状态)。从设计角度来说,抽象类代表的是一种“是什么”,而接口更像是“能做什么”,这种区分在面试时算是挺重要的。
而我个人觉得,选择使用抽象类还是接口,也主要看场景。比如说,如果有一系列类本质上属于同一类,具有共性,并且需要共享一些实现的话,用抽象类更好;如果是希望不同的、不相关的类具备某种能力,比如可以序列化、可以比较之类的,那么接口显然更合适。
电脑输入一个URL,到打开页面中间的过程
当你在电脑上输入一个 URL 并回车后,其实整个过程涉及到很多细节,不仅仅是简单地“打开网页”。我一般会这么跟面试官说:
-
首先,浏览器接收到你输入的 URL,会先解析这个 URL,确认协议(http 或 https)、域名、路径等信息。这里浏览器还会检测这个 URL 是否正确、符合规范。
-
接下来,浏览器就进入到 DNS 查询阶段,也就是把域名翻译成一个 IP 地址,这个过程可能会先查本地缓存、操作系统缓存,再到 DNS 服务器查询。这个过程很关键,因为没有正确的 IP,就无法建立连接。
-
当拿到 IP 地址后,浏览器开始跟服务器建立 TCP 连接。对于 HTTPS 请求,还要进行 SSL/TLS 握手,这一步比较耗时,主要用来保证传输安全。建立连接时会经过三次握手,确保双方能够正确通信。
-
连接建立之后,浏览器构造 HTTP(或 HTTPS)请求,其中包括了请求头、请求方法、可能的请求体等信息,并将这个请求发送给服务器。这里可能还会带上浏览器信息、cookie 等数据,因为服务器需要根据这些信息返回合适的页面或者资源。
-
服务器收到请求后,处理相关业务逻辑,比如查询数据库、生成 HTML 页面等,然后返回一个 HTTP 响应。这个响应里包含状态码、响应头和响应体,比如 HTML、CSS、JavaScript、图片等资源。
-
此时浏览器收到响应,首先根据状态码判断请求是否成功,然后开始解析 HTML 文件。浏览器会根据 HTML 结构构建 DOM 树,同时遇到 CSS 文件时,也会构建渲染树,这一步非常关键,因为浏览器需要知道如何把页面按预期样式呈现给你。
-
除了基本的 HTML、CSS 解析外,浏览器也会执行 JavaScript 代码,有时候这个过程会触发更多的资源请求(比如通过 AJAX 异步加载后续数据)或者动态修改页面内容。
-
最后,在所有资源都加载完,渲染树构建完成之后,浏览器把页面真正绘制到屏幕上,这时你就能看到那个网页了。这个过程中还可能涉及到缓存策略、重定向等细节。
整个过程其实从输入 URL 到页面展示,中间涉及了网络传输、协议解析、资源加载、渲染优化等多个环节。
HTTP状态码含义,然后说说常用的状态码
首先,1xx系列状态码是信息性状态,它们表示请求已经被接收,继续处理。对我们实际开发来说,这类状态码比较少遇到,因为它们主要是底层的通信细节。
最常用的就是2xx系列,表示请求成功。例如,200状态码就非常常见,意味着请求被成功处理了,服务器返回了内容。还有201,这个一般用在我们做POST操作后,表示资源已经创建成功,但大部分场景下,200是最主要的。
3xx系列状态码代表重定向。比如301永久重定向,告诉客户端以后应该使用新的URL来访问资源;302临时重定向则表明资源暂时被移到其他地方,这种重定向在实际应用中也比较常见,比如在网站更新地址或者做A/B测试时会用到。
接着是4xx系列,表示客户端错误。这个里面比较常见的有400,代表请求存在格式或者参数问题;401表示要求身份认证,也就是我们经常看到的需要登录或者token校验失败;403则是权限问题,告诉你这个资源是不允许访问的;404则是最熟悉的,就是资源找不到,可能是链接错误或者资源已经下线。
最后是5xx系列,代表服务器错误。比如500就是服务器内部错误,表示服务器处理请求时出了问题;502通常是网关错误,说明服务器作为网关或者代理时收到了无效响应;503代表服务不可用,可能是服务器正在维护或负载过高。
为什么A的onStop()在B的onResume之后 (Activity A 启动 B)
当Activity A启动Activity B时,系统的生命周期回调顺序其实是经过精心设计的,目的是为了确保用户看到的是平滑无缝的过渡体验。
具体来说,当A调用startActivity启动B时,A首先会进入onPause,这时候A并没有完全被销毁或者隐藏,而是暂停了用户交互,等待转场完成。接下来,B会开始其生命周期,从onCreate、onStart一路到onResume,这个时候B已经完全进入前台并能和用户互动。只有当B完全显示并稳定下来后,系统才会将A视为“不可见”,然后调用A的onStop。
这种设计的原因主要在于,系统希望先确保新活动(B)的UI已经完全就绪和渲染完毕,才把旧的活动(A)挂起或停止。这样可以避免在过渡阶段出现空白页面或闪烁现象,同时也让动画和视觉体验更为流畅。简单来说,就是为了保证B在用户眼前完美呈现,系统会延迟A的onStop,直到B完全进入稳定的交互状态。
此外,这个流程也有助于开发者在onPause中保存必要的状态,然后在onStop中释放占用的资源。因为从用户的角度看,A很快就会看不见了,所以在B的onResume之后调用A的onStop是比较符合逻辑的。这样既能确保过渡的流畅性,又能让开发者有足够的时间处理A的状态保存和资源清理。
安卓里面的常用布局及其作用
首先说说LinearLayout,它是一种非常简单的布局,主要作用就是实现水平或者垂直方向的排列。它的优点就是使用简单,但当嵌套层次较多时,性能可能不太好,因为每一层都需要一次布局计算。不过在简单的场景下,比如一排按钮或者简单的文字列表,LinearLayout已经足够了。
接着是FrameLayout,它其实比LinearLayout更加简单,只能堆叠子视图。通常我们会把FrameLayout用于显示单一控件或者做一些背景覆盖的效果,比如在一个图片上叠加一些文字或者进度条,这种场景下FrameLayout就非常实用。
然后是RelativeLayout,在以前开发中使用得相当广泛,它的好处是可以让你根据其他子控件的相对位置来布局,非常适合一些不规则的界面。但是在开发过程中往往你会发现它容易导致比较复杂的嵌套层次,尤其当界面元素非常多的时候,维护起来就有点麻烦。
随着Android的发展,现在更推荐使用ConstraintLayout。ConstraintLayout的优势在于一方面可以像RelativeLayout那样根据约束关系进行布局, 另一方面能实现较复杂的UI而不用层层嵌套,其响应式布局能力和性能优势也很明显。它支持链式布局、偏移以及各种动态约束,开发起来更灵活,并且在编辑器中可视化调整也很方便。
除此之外,还有CoordinatorLayout。这种布局更多用于处理一些高级的交互场景,比如顶部的AppBar、悬浮按钮(FloatingActionButton)以及SnackBar之间的联动效果。它可以帮助我们在交互过程中实现自动隐藏、漂浮以及回弹等动画,让用户体验更加流畅。
总结来说,我会根据具体的需求选择对应的布局:简单线性排列就用LinearLayout,页面层叠简单的就用FrameLayout,需要相对定位用RelativeLayout,但随着更高级需求的出现,ConstraintLayout就成了大多数复杂布局的首选;而一些特定的交互效果则可以借助CoordinatorLayout来实现。
一个RecyclerView如何实现每个item样式不一样
其实如果想让RecyclerView的每个item样式不一样,有两种主要思路。首先是通过在Adapter中重写getItemViewType方法,根据每个position(或者具体业务数据)返回不同的整数类型标识,然后在onCreateViewHolder中根据这个返回值来加载不同的布局文件。这样就可以保证每种类型的item使用不同的ViewHolder和布局,从而实现样式多样化。
另一种方式就是把所有样式放在同一个布局中,然后在绑定数据时根据具体情况动态显示或者隐藏某些控件,这种方式一般适用于样式差异不太大的情况,但是逻辑就会比较复杂,需要手动控制每个控件的可见性和数据绑定。因此,我一般更倾向于使用第一种方式,因为这种模式清晰直观,扩展性强,也符合RecyclerView的设计思想。
另外,我会提醒面试官,使用不同类型的item会在ViewHolder的设计上带来一些额外的管理成本,比如必须管理多个ViewHolder和对应的布局文件,但整体来说,收益是页面展示更加灵活,用户体验更好。对于大数据量的RecyclerView,也可以利用复用机制保证性能不会变差。
总之,我会强调:核心思路就是根据数据的不同返回不同的Item类型,然后在onCreateViewHolder中动态加载对应布局,这样既能满足样式多样化的需求,又能利用RecyclerView的复用机制保持效率和流畅性。
getItemViewType
是 Android 中 RecyclerView.Adapter
类的一个方法,用于为 RecyclerView
的每个 item
指定一个视图类型(ViewType
)。它的核心作用是支持在同一个 RecyclerView
中使用不同类型的布局。
RecyclerVIew怎么用的
首先,RecyclerView其实是Android中用来高效展示大量数据的一种容器,它的核心思想就是复用机制,避免一次性创建大量View,从而减少内存消耗和提高效率。相比之前的ListView或者GridView,RecyclerView更加灵活,也支持更多自定义布局和动画效果。
具体使用的时候,我会这样考虑:
-
需要先在布局文件中把RecyclerView放进来,然后在Activity或者Fragment中通过findViewById获取到这个RecyclerView的实例。
-
接着,你得配置LayoutManager,它决定了列表的布局形式。如果是线性列表,就用LinearLayoutManager;如果是网格排列,则使用GridLayoutManager;有时候如果需要更自由的布局,可以使用StaggeredGridLayoutManager。选择哪个主要看你具体的业务需求和界面的样式。
-
下一步就是设置Adapter。Adapter其实负责把你后端的数据绑定到各个Item的布局上。它通常会有一个ViewHolder用于缓存item中的各个控件。这样在滚动过程中,RecyclerView会不断回收和重用这些ViewHolder,而不是每次都新建,从而达到节省性能的效果。Adapter里面最主要的两个方法是onCreateViewHolder和onBindViewHolder,前者用来创建新的ViewHolder,而后者用来绑定数据到已经存在的ViewHolder上。
-
除了基本的数据绑定之外,有时候你还会希望支持item的点击事件、长按事件之类的,这些一般可以在ViewHolder中设置监听器,或者在Adapter中回调到Activity/Fragment中处理。
-
除此之外,RecyclerView还支持添加ItemDecoration,比如分割线、间距、边距等效果,能够让列表看起来更加整洁。此外,通过设置ItemAnimator你还可以实现item添加、删除时的动画效果。
-
最后,如果页面数据会经常变动,那么我们还可以通过DiffUtil这样的工具来更高效地更新RecyclerView,避免直接调用notifyDataSetChanged这种暴力刷新方式,从而实现流畅的用户体验。
ViewHolder是用来干什么的
ViewHolder主要就是用来优化RecyclerView性能的。在RecyclerView中,每个item都有一个对应的布局,如果我们每次显示item时都要通过findViewById去查找里面的各个控件,那效率会非常低,尤其当列表数据比较多或者item很复杂时,这个问题就更明显了。
我一般会解释说,ViewHolder的作用就是在item创建的时候把布局里面的关键控件提前缓存起来,这样当我们在数据更新或者滚动时,RecyclerView就可以直接拿着这些缓存好的引用来修改数据显示,而不必每次都去查找一次控件。这样不仅能够减少findViewById的开销,还能大幅提升RecyclerView滚动时的流畅度。
基本上,每个ViewHolder就保存了item布局中的所有控件的引用,当RecyclerView回收一个item的时候,再次复用这个item的时候,它已经拥有了所有必要的控件引用。所以在onBindViewHolder中,我们直接用ViewHolder来设置相应的数据显示就好了。
Fragment与Activity之间传递数据
首先,说到Fragment与Activity之间的数据传递,主要有两种方向:一种是Activity向Fragment传数据,另一种是Fragment向Activity传数据。每个方向都有不同的方式,我一般会详细讲一下常用的方法以及注意点。
Activity向Fragment传数据的话,最常用的方式是在创建Fragment时通过Bundle传递参数。也就是说,在Activity中创建Fragment实例的时候,通过setArguments将数据以Bundle的形式传进去,Fragment在自己的生命周期早期,比如onCreate中就能通过getArguments来拿到这些数据。这种方式的优势在于是一次性的传参,而且Bundle是序列化的,所以也可以在系统重建Fragment时恢复数据。
另外一个方向,Fragment向Activity传数据,常见的做法通常是使用回调接口。也就是说,在Fragment里定义一个接口,然后Activity实现这个接口,并在Fragment的onAttach中把Activity赋值给接口变量。这样,Fragment在需要传数据的时候,直接调用接口方法,就能把数据返回给Activity。这样解耦合的方式主流而且易于维护。也有一些项目中会使用EventBus或者LiveData来进行交互,尤其是在复杂项目中这种机制可以让数据传递不依赖具体组件的引用,但是我觉得回调方式更直观一些。
此外,还有一种简单方法就是直接通过Activity中公开的方法来获取Fragment中的数据或者相反,不过这种方式会导致Fragment和Activity之间耦合性增高,不太推荐。还有一种就是共同存到共享ViewModel里,这对于使用了Jetpack架构组件的项目来说非常方便,尤其是在MVVM模式下,可以让多个Fragment和Activity共享数据,也对生命周期管理有好处。
总结下来,我会强调的是,选择哪一种方式主要看业务场景和项目架构要求:如果只是单纯的初始化参数,使用Bundle传参就足够;如果需要动态交互,那么回调接口或者共享ViewModel的方式更合适。
set Arguments和写一个接口传参数有什么区别
首先,setArguments一般用在Fragment和它的创建过程中。当我们想在创建Fragment的时候就传入一些初始化数据,比如说显示的信息或者某些必要的参数,这时候就会把数据放到一个Bundle里,然后调用Fragment的setArguments方法。这样传递的数据在Fragment生命周期中会被保留下来,即使发生像配置变化或者重建Fragment的情况,也能通过getArguments拿到之前的值。它的好处就是简单、直接,而且数据是只读的,主要是在Fragment初始化阶段使用。
而写一个接口传参数通常是针对后续的交互场景来说。比如说Fragment和Activity之间互相通信,当Fragment执行了一些操作之后需要通知Activity,把一些操作结果传递过去,这时候就常常定义一个接口,由Activity去实现,然后在Fragment中回调这个接口方法来传递数据。这种方式是为了让组件之间解耦合,更灵活,不仅仅局限于初始化阶段,而是在运行过程中动态交互数据。
所以简单总结一下:setArguments适用于在Fragment新建时传入初始参数,这种方式是一次性和不可变的,通常在onCreate里拿出来用。而接口则是一个持续性的通信机制,它可以在Fragment的任意时刻把数据传回到Activity或者其他宿主组件,而且这种方式更灵活,也能处理一些交互性比较强的场景。
setArguments
是 Android 中 Fragment
类的一个方法,用于在创建 Fragment
时传递数据。它通过 Bundle
对象来携带参数,方便我们在 Fragment
与其他组件(如 Activity
或另一个 Fragment
)之间进行数据传递。
Java创建对象的几种方法
首先,最常见的方式当然是通过new关键字来创建对象。这是最直接、最简单的方式,直接调用类的构造函数初始化对象。在大多数场景下,我们就是这么做的,因为它简洁明了,适合对对象进行明确赋值和状态初始化。
其次是利用反射来创建对象。比如说使用Class.forName().newInstance()或者constructor.newInstance()来创建对象,这种方式虽然没有new关键字直观,但在一些框架里面,比如依赖注入、插件系统或者动态加载的时候,反射就很有用。它的优点在于可以在运行时动态确定具体的类,但缺点就是性能上会稍微逊色,还会有安全性和异常处理的需求。
还有一种方式是通过clone()方法来创建对象。我们可以让一个类实现Cloneable接口,然后在调用clone方法的时候返回一个全新的对象。使用clone有时候可以快速创建一个原型对象的深拷贝,但这个方法比较容易出问题,尤其是对复杂对象的深层次嵌套,很多时候我们需要自己手动处理深拷贝的问题,所以很多时候我会倾向于其他更明确的方式。
另外,还有通过反序列化来创建对象。这个方式看起来有点“绕弯子”,主要是当对象进行序列化存储后,再反序列化回来得到一个新对象。在某些场景下,比如需要通过网络传输对象的时候,可以利用反序列化自动恢复对象状态,不过这种方法比较依赖对象的序列化机制,也不适合频繁创建对象的场景。
最后,还有工厂模式来创建对象。这其实算是一种设计模式,通过封装对象创建逻辑,把new操作封装到工厂方法中,让调用者无需直接操作构造函数。这样既可以隐藏具体实现,也方便做一些逻辑判断或者配置化创建对象的处理。比如说在工厂类内部你可以根据传入的参数来决定创建哪种具体的对象。这样做的好处是扩展性好,而且有利于解耦。
总结一下:
- new关键字是最直观和高效的方式,用来创建简单且明确的对象。
- 反射能够动态加载类,适用于框架和插件等需要灵活动态创建对象的场景。
- clone()主要用于需要复制已有对象的场景,但需要注意深浅拷贝问题。
- 反序列化适用于将对象状态持久化或者通过网络传输后再创建对象。
- 工厂模式则是通过封装new操作来让对象创建逻辑更加灵活和解耦。
// 产品接口
/*
internal 修饰符用于限制某个类、方法或者属性的可见性,使其仅在同一个模块内可见
*/
internal interface Product {
fun display()
}
// 产品A的实现
internal class ProductA : Product {
override fun display() {
println("This is Product A")
}
}
// 产品B的实现
internal class ProductB : Product {
override fun display() {
println("This is Product B")
}
}
// 工厂类
internal object ProductFactory {
// 静态方法:根据不同的类型创建不同的产品
fun createProduct(type: String): Product {
return if ("A".equals(type, ignoreCase = true)) {
ProductA()
} else if ("B".equals(type, ignoreCase = true)) {
ProductB()
} else {
throw IllegalArgumentException("Unknown product type: $type")
}
}
}
// 测试类
object FactoryPatternExample {
@JvmStatic
fun main(args: Array<String>) {
// 使用工厂创建产品A
val productA = ProductFactory.createProduct("A")
productA.display() // 输出: This is Product A
// 使用工厂创建产品B
val productB = ProductFactory.createProduct("B")
productB.display() // 输出: This is Product B
}
}
反序列化
反序列化其实就是把之前序列化后的数据(通常会以某种格式,比如JSON、XML或者二进制流)还原成一个对象的过程。反序列化这个过程在很多场景下都能见到,比如说当你从网络请求中收到数据,需要把它转换成对象供你后续使用,或者保存数据到本地后再还原成对象,这就离不开反序列化。
我会特别讲到几个关键点。首先,反序列化不仅能把数据还原成对象,对于数据的完整性和安全性上也需要注意,比如说我们要确保反序列化的数据是可信的。如果反序列化不当,也可能引发安全漏洞,比如反序列化攻击。过去有很多案例表明,如果攻击者能控制反序列化的输入,就可能执行恶意代码,所以我们在实际操作中应该特别注意数据来源和对反序列化数据的校验。
其次,反序列化与序列化是相对的,都是将对象与数据格式之间转换的过程。我们在做反序列化时,通常会依赖一些库或者框架,比如在Java里常用的就是Jackson或者Gson这些工具,它们能够自动把JSON数据转换为Java对象。当然,使用这些工具的灵活性也同样需要开发者理解对象属性和数据格式之间的对应关系,确保映射准确且没有漏掉或者误解数据。
还有,我会提到系统原生的反序列化机制,在Java中,比如Serializable接口的实现,就是用来进行对象的序列化和反序列化的。尽管系统的方式较为简单,但它的可控性和性能有时候就稍显不足,尤其在跨平台或者版本兼容性上可能存在问题。所以很多项目中,我们多半选择第三方库来保证更好的兼容性和灵活度。
总体来说,我强调反序列化的本质就是数据格式和对象状态之间的转换,同时也得考虑到安全、性能和版本管理。
深拷贝和浅拷贝
首先,说浅拷贝的时候,它仅仅是复制一份引用,也就是说创建一个新对象,但它内部的属性其实还是指向原来对象所引用的内存。如果内部包含的是基本数据类型,那拷贝的也是值,但是如果属性是一种引用类型,比如数组、对象等,那么它们就会共享同一份数据。你可以把它想象成两个指针指向同一个内存区域,所以如果你在其中一个中修改了这个引用类型的数据,另一个也会感受到变化。
而深拷贝则不一样,深拷贝会创建一个全新的对象,同时递归地复制这个对象内部所有引用的对象。也就是说,深拷贝后的对象和原对象就完全解耦了,任何一边的修改都不会影响到另一边。深拷贝常常用于我们需要一个完全独立的副本并防止数据互相干扰的场景。但你也会注意到,深拷贝的过程相对来说要消耗更多资源,因为它需要遍历对象图,对每个引用类型都进行复制。
我在面试中会特别强调这点:选择深拷贝或者浅拷贝,主要取决于场景。如果对象内部结构简单且不需要完全独立,例如只用了基本数据或者你确定不在意共享同一份引用,那么浅拷贝就足够了;但是如果对象内部有复杂的引用结构,或者你需要完全隔离一下状态,就必须用深拷贝。另外,深拷贝也有一些性能损耗的顾虑,因此实际开发中需要平衡好数据安全和性能之间的关系。
内存泄漏,profile用法
首先,内存泄漏是在我们的Android应用中比较常见的一种问题,主要就是某些对象由于没有被正确回收或者引用还存在,导致持续占用内存,久而久之就可能引起OOM或者影响性能。我会提到,引起内存泄漏的常见原因有,比如未解除的静态引用、非静态内部类隐式持有外部类的引用、单例不恰当持有Context,甚至用第三方库的时候不注意销毁回调或者监听器等等。要避免这种情况,我们需要在设计上特别注意,如使用Application Context而非Activity Context、在合适的生命周期中解除注册、使用弱引用(WeakReference)等等。
说到Profile工具,它其实是Android Studio内置的性能分析工具,比如Memory Profiler和CPU Profiler。用Memory Profiler,我们可以监控应用运行时的内存使用情况,通过实时图表了解内存占用的峰谷变化。如果怀疑有内存泄漏,就可以用Memory Profiler来抓取heap dump,看看哪些对象没有被回收,定位到代码中的泄露点。比如说,我们可能会看到某个Activity或Fragment一直存在,而它本应该在退出后被销毁。
我通常会解释如何使用这些工具:启动Android Profiler,把设备或者模拟器连接好后,然后在Memory选项卡中,观察内存的实时走势。当出现异常增加时,就可以点下去进行heap dump,并使用分析工具来查找不正常的内存引用链。通过这些引用链,我们就能找出到底是哪个对象持有了本不应该存在的引用,然后回到代码中进行修正。Profile工具还能帮助开发者定位方法的资源分配以及垃圾回收的频率,从而更好地优化应用性能。
总的来说,这两者虽然看起来是两个概念,但它们的核心都是在帮助我们理解和监控应用的运行状态。内存泄漏问题如果不及时解决,可能会累计引发严重性能问题;而Profile工具则提供了直观的数据和可视化分析手段,让我们能够快速定位问题并验证修复效果。
内存泄漏角度讲:静态内部类和匿名内部类有什么区别
首先,静态内部类(static inner class)本身跟外部类没有隐式的引用。这意味着如果你把一个静态内部类作为一个工具类或者其他需要较长生命周期的组件,就不会因为它对外部类的引用而导致外部类无法被垃圾回收,避免了不必要的内存泄漏。举个例子,如果一个Activity里面有个静态内部类去处理一些异步任务,由于它不隐式指向Activity,所以即使任务还在执行,Activity也可以顺利被销毁,不会被意外持有。
而匿名内部类通常都是非静态的,因为它们写在某个方法或者代码块中,默认会隐式持有外部类的引用(比如Activity)。这会有个问题:如果匿名内部类的生命周期超出外部类,比如说一个异步回调或者定时器任务在Activity销毁后还引用了这个匿名内部类,那么它可能会导致Activity的实例无法回收,从而引发内存泄漏。
我会再强调一下一点:在实际开发中,如果可以的话,我们一般建议对于需要避免内存泄漏的场景,尽量使用静态内部类,并在必要时通过弱引用(WeakReference)持有外部类的引用,这样可以在保证功能需求的同时,尽量避免长时间占用Activity或Fragment的内存。对于匿名内部类,在使用的时候一定要特别注意它是否无意中拖住了外部类,特别是在异步、回调等场景下,最好及时释放或者使用更安全的方式来替代。
总结一下,我会告诉面试官:静态内部类由于不隐式持有外部类引用,天然上更安全,能够降低内存泄漏风险;而匿名内部类因为持有外部类的引用,在长生命周期的操作中可能引发内存泄漏,需要额外关注。
给匿名内部类声明成静态的,可以持有外部类吗?为什么?
匿名内部类不能声明为静态的,因为Java语言规范不允许在匿名内部类上加上static修饰词。匿名内部类总是隐式地持有外部类的引用,也就是说,它总是与外部类的实例绑定在一起,这样才能访问外部类的成员变量和方法。
如果想达到没有外部类引用的效果,我们通常会使用静态内部类(也叫静态嵌套类),因为静态内部类本身不会持有外部类的实例引用。这样在某些场景下,比如异步任务或者回调操作中,可以避免意外持有Activity或者其他组件的引用,进而降低内存泄漏的风险。但归根结底,匿名内部类不具备被声明为静态的能力,所以也就不能像静态内部类那样不持有外部类引用。
讲一下volatile关键字,用了它就能保证线程安全吗?为什么
volatile主要是用来保证内存可见性,也就是说,当一个变量被声明为volatile后,一个线程对它的写操作,其他线程能马上看到这个写入。这就防止了线程内部缓存值的问题。另外,volatile还能防止指令重排序,保证一定程度上的顺序性。
但是要注意,用了volatile并不代表就实现了线程安全。它只保证了单一写操作对其他线程的可见性,而不是原子性。比如说,如果有多个线程同时对一个volatile变量进行复合操作(例如自增操作),这个操作其实包含多个步骤(读取当前值、计算新值、写入新值),volatile无法保证这整个过程的原子性。所以,如果需要进行复合操作或者多个变量之间的协调工作,单靠volatile是远远不够的,这时候就需要synchronized或者其他并发工具来保证原子性和线程安全。
总结一下,我会告诉面试官,volatile能解决可见性问题和部分顺序性问题,但它不能保证线程安全,特别是在涉及复合操作、竞态条件或者多个操作不可分割的场景下,必须结合更强的同步手段来保障线程安全。
异常都有什么
首先,从Java的角度讲异常,其实可以分成两大类,一类是异常(Exception),另一类是错误(Error)。异常里面还可以再分为受检异常(checked exceptions)和非受检异常,也就是运行时异常(runtime exceptions)。
受检异常是在编译期就要求去处理的,比如文件读写操作可能会抛出的IOException,这类异常不处理的话编译器就不会通过。它们一般是我们可以预见到的情况,比如网络请求出错、文件不存在等等,所以要求程序员在调用方法时必须处理,比如try catch块或者抛出异常。
而非受检异常(运行时异常)则是在运行的时候可能出现的问题,比如空指针异常(NullPointerException)、数组越界(ArrayIndexOutOfBoundsException)或者算术异常(ArithmeticException)。这些异常通常代表程序内部逻辑有问题,或者不太可能预料到的错误情况。它们在编译期是不强制要求处理的。但这类异常如果出现,往往意味着代码需要优化,因为它们暴露了潜在的漏洞。
至于Error,其实不是异常,而是系统级的问题,比如OutOfMemoryError或者StackOverflowError。这些错误通常表示JVM内部遇到了严重问题,程序一般无法处理,也不提倡捕获它们,因为一旦它们发生,往往说明根本问题已经出现,程序基本都快崩溃了。
在Android和Kotlin相关开发中,我们也需要根据具体业务场景来选择如何捕获和处理这些异常。有时候我们会选择在最外层写个统一的异常处理,例如捕获一些不可控的运行时异常来做日志上报或者显示友好的提示;而对于受检异常,我们更多的是通过合理的判断和错误码去处理。
另外,还有自定义异常的情况,比如针对项目的业务逻辑,自己定义一些异常继承自Exception或RuntimeException,这样可以把业务的异常情况变得更清晰,便于处理和调试。
总的来说,我会强调知道Java里的异常有这几个层次:受检异常要求在编译期处理,运行时异常则是代表一些逻辑或程序bug,而Error则属于JVM本身无法控制的问题。