Android 上 String.hashCode 的实现稳定吗?

文章探讨了在数据库主键优化中使用String.hashCode()方法可能存在的哈希冲突问题和跨ART实例/系统版本的稳定性。尽管理论上有冲突风险,但在实践中,由于hashCode的分布特性,冲突概率较低。对于Android系统,String.hashCode的实现被认为在不同版本间是稳定的,尽管历史上存在过不一致的问题,但后续版本已经修复并保持兼容。开发者倾向于依赖IDE生成更稳定的hashCode实现来确保持久化存储的场景。

今天在排查反馈问题的时候,注意到有一处逻辑将几个属性拼接成 String 再计算 hashCode 作为表的主键使用。显然这是一处对于 DB 数据索引的小优化( 对比传统复合主键写法来说 ),但理论上可能存在两个问题:

  • 数据项间存在主键计算结果哈希冲突的情况,会导致 DB 数据错乱
  • String 的 hashCode 实现跨 ART 实例 / 系统版本不稳定,会导致 DB 数据无法被正确索引

具体实践上来说,在项目主工程中全局搜索了下,发现有多处类似的写法( 大概是出现了历史代码人传人的情况 ),最早的提交记录可以追溯到 15 年下半年,且某些表重度用户应该会出现上万条表记录的情况,基于此可以认为截止到当下,上述两个问题都不太可能会出现。

哈希冲突的问题,理论上分布均匀的 hashCode 实现,大约超过 70k 条数据就会有 50% 的概率出现,详细讨论可参见 String.hashCode() is plenty unique

至于 String.hashCode 实现的稳定性,是否有一个明确的约定呢?

从系统版本跨 ART 实例来说( 即同一 ART 实现跨进程的情况 ),String.hashCode 的实现显然是稳定的。

从 String.hashCode 的文档上也可以看出来: 

 注意到文档上甚至直接规定了 String.hashCode 的算法,实际也就是 Android Studio 默认帮我们补全 hashCode 实现的那一种。

从跨 Android 系统版本来说,String.hashCode 的实现是否是保证稳定的呢?

前面提到 String 的接口文档上是直接规定了 hashCode 的算法的,但接口文档本身理论上也可以随着版本的变更而变更,是否有这么一个地方明确定义了 String.hashCode 的跨版本兼容性呢?

于是我问了下 chatGPT,它的答案是 Java 语言规范中明确规定了 String 的 hashCode 算法,给出的引用是这样的: 

 似乎很有道理,我顺着链接 jls-17.5.3 找了下 JLS 的内容,发现引用的章节实际既不是讲 String 也不是讲 hashCode 的,没关系,前后几个版本的 JLS 也全局搜索下 hash,结果也没有相关内容,果然 chatGPT 又是在一本正经地逗我...

只能自力更生 Google 了,翻到了一个上古年代 JDK 的 bug 记录 java.lang.String.hashCode spec incorrectly describes the hash algorithm,里面提到了最早的 JLS 上确实描述了 String.hashCode 的算法,但实现却和规范不符,这个问题在 JDK 1.2 上被修复,同时 JLS 的描述应该被相应地更新( 看来 chatGPT 虽然骗了我,但又没有完全骗 )。不过 SE1.2 对应的 JLS 文档简单找了下没找到,而 SE6 对应的 JLS 文档上就确实没有 String.hashCode 的相关描述了。这里可以看到至少在上古年代的 JDK 实现上,String.hashCode 确实是存在有实现不一致的情况的,不过这跟 Android 是没啥关系了。

发现 Stack Overflow 很多年前也就有过类似的提问: Consistency of hashCode() on a Java string,从讨论内容看,普遍的共识是从向后兼容考虑,大概率 JDK 的实现也不会修改这个长久以来都很稳定的 String.hashCode 算法了。

Android 上 String.hashCode 的实现稳定吗?大概它是稳定的。不过对于类似需要计算 hashCode 再持久化存储的场景,我选择直接让 IDE 帮我生成一个绝对稳定的 hashCode 实现 🐶。

package com.example.savestatedhandledemo import android.os.Bundle import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.lifecycle.ViewModelProvider import com.example.savestatedhandledemo.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) val viewModel = ViewModelProvider(this).get(MainSaveViewModel::class.java) Log.d("ViewModelDebug", "当前 ViewModel hashCode: ${viewModel.hashCode()}") viewModel.userInfo.observe(this) { binding.tvInfo.text = it } } } package com.example.savestatedhandledemo import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.widget.Button import androidx.appcompat.app.AppCompatActivity class StartActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_start) val button = findViewById<Button>(R.id.btn_go_to_main) button.setOnClickListener { // 跳转到 SecondActivity val intent = Intent(this, MainActivity::class.java) startActivity(intent) } } } <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.SaveStatedHandleDemo"> <activity android:name=".StartActivity" android:exported="true" android:theme="@style/Theme.SaveStatedHandleDemo"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <!-- SecondActivity --> <activity android:name=".MainActivity" /> </application> </manifest> 有什么问题吗
09-12
Exception occurred while executing: android.os.ParcelableException: java.io.IOException: java.lang.NullPointerException: Attempt to invoke interface method 'boolean android.os.IInstalld.isQuotaSupported(java.lang.String)' on a null object reference at android.util.ExceptionUtils.wrap(ExceptionUtils.java:34) at com.android.server.pm.PackageInstallerService.createSession(PackageInstallerService.java:406) at com.android.server.pm.PackageManagerShellCommand.doCreateSession(PackageManagerShellCommand.java:2415) at com.android.server.pm.PackageManagerShellCommand.runInstall(PackageManagerShellCommand.java:907) at com.android.server.pm.PackageManagerShellCommand.onCommand(PackageManagerShellCommand.java:158) at android.os.ShellCommand.exec(ShellCommand.java:103) at com.android.server.pm.PackageManagerService.onShellCommand(PackageManagerService.java:21336) at android.os.Binder.shellCommand(Binder.java:634) at android.os.Binder.onTransact(Binder.java:532) at android.content.pm.IPackageManager$Stub.onTransact(IPackageManager.java:2821) at com.android.server.pm.PackageManagerService.onTransact(PackageManagerService.java:3856) at android.os.Binder.execTransact(Binder.java:731) Caused by: java.io.IOException: java.lang.NullPointerException: Attempt to invoke interface method 'boolean android.os.IInstalld.isQuotaSupported(java.lang.String)' on a null object reference at com.android.server.usage.StorageStatsService.isQuotaSupported(StorageStatsService.java:163) at android.app.usage.StorageStatsManager.isQuotaSupported(StorageStatsManager.java:69) at android.app.usage.StorageStatsManager.isQuotaSupported(StorageStatsManager.java:78) at com.android.server.StorageManagerService.getAllocatableBytes(StorageManagerService.java:2916) at android.os.storage.StorageManager.getAllocatableBytes(StorageManager.java:1754) at com.android.internal.content.PackageHelper.resolveInstallVolume(PackageHelper.java:188) at com.android.internal.content.PackageHelper.resolveInstallVolume(PackageHelper.ja
10-26
我现在有个想法就是ResultAdapter能够同时兼顾SearchResultActivity和MapFragment。所以就是说MapFragment的代码尽量像SearchResultActivity那样,然后结合我上面所说的四点要求,达到即可。我给你提供Poiltem的原因是告诉你这里面只有这些东西:// // Source code recreated from a .class file by IntelliJ IDEA // (powered by FernFlower decompiler) // package com.amap.api.services.core; import android.os.Parcel; import android.os.Parcelable; import com.amap.api.services.poisearch.IndoorData; import com.amap.api.services.poisearch.Photo; import com.amap.api.services.poisearch.PoiItemExtension; import com.amap.api.services.poisearch.SubPoiItem; import java.util.ArrayList; import java.util.List; public class PoiItem implements Parcelable { private String a; private String b; private String c; private String d; private String e = ""; private int f = -1; private final LatLonPoint g; private final String h; private final String i; private LatLonPoint j; private LatLonPoint k; private String l; private String m; private String n; private String o; private String p; private String q; private String r; private boolean s; private IndoorData t; private String u; private String v; private String w; private List<SubPoiItem> x = new ArrayList(); private List<Photo> y = new ArrayList(); private PoiItemExtension z; private String A; private String B; public static final Parcelable.Creator<PoiItem> CREATOR = new Parcelable.Creator<PoiItem>() { private static PoiItem a(Parcel var0) { return new PoiItem(var0); } private static PoiItem[] a(int var0) { return new PoiItem[var0]; } }; public PoiItem(String var1, LatLonPoint var2, String var3, String var4) { this.a = var1; this.g = var2; this.h = var3; this.i = var4; } public String getBusinessArea() { return this.v; } public void setBusinessArea(String var1) { this.v = var1; } public String getAdName() { return this.r; } public void setAdName(String var1) { this.r = var1; } public String getCityName() { return this.q; } public void setCityName(String var1) { this.q = var1; } public String getProvinceName() { return this.p; } public void setProvinceName(String var1) { this.p = var1; } public String getTypeDes() { return this.e; } public void setTypeDes(String var1) { this.e = var1; } public String getTel() { return this.b; } public void setTel(String var1) { this.b = var1; } public String getAdCode() { return this.c; } public void setAdCode(String var1) { this.c = var1; } public String getPoiId() { return this.a; } public int getDistance() { return this.f; } public void setDistance(int var1) { this.f = var1; } public String getTitle() { return this.h; } public String getSnippet() { return this.i; } public LatLonPoint getLatLonPoint() { return this.g; } public String getCityCode() { return this.d; } public void setCityCode(String var1) { this.d = var1; } public LatLonPoint getEnter() { return this.j; } public void setEnter(LatLonPoint var1) { this.j = var1; } public LatLonPoint getExit() { return this.k; } public void setExit(LatLonPoint var1) { this.k = var1; } public String getWebsite() { return this.l; } public void setWebsite(String var1) { this.l = var1; } public String getPostcode() { return this.m; } public void setPostcode(String var1) { this.m = var1; } public String getEmail() { return this.n; } public void setEmail(String var1) { this.n = var1; } public String getDirection() { return this.o; } public void setDirection(String var1) { this.o = var1; } public void setIndoorMap(boolean var1) { this.s = var1; } public boolean isIndoorMap() { return this.s; } public void setProvinceCode(String var1) { this.u = var1; } public String getProvinceCode() { return this.u; } public void setParkingType(String var1) { this.w = var1; } public String getParkingType() { return this.w; } public void setSubPois(List<SubPoiItem> var1) { this.x = var1; } public List<SubPoiItem> getSubPois() { return this.x; } public IndoorData getIndoorData() { return this.t; } public void setIndoorDate(IndoorData var1) { this.t = var1; } public List<Photo> getPhotos() { return this.y; } public void setPhotos(List<Photo> var1) { this.y = var1; } public PoiItemExtension getPoiExtension() { return this.z; } public void setPoiExtension(PoiItemExtension var1) { this.z = var1; } public String getTypeCode() { return this.A; } public void setTypeCode(String var1) { this.A = var1; } public String getShopID() { return this.B; } public void setShopID(String var1) { this.B = var1; } protected PoiItem(Parcel var1) { this.a = var1.readString(); this.c = var1.readString(); this.b = var1.readString(); this.e = var1.readString(); this.f = var1.readInt(); this.g = (LatLonPoint)var1.readValue(LatLonPoint.class.getClassLoader()); this.h = var1.readString(); this.i = var1.readString(); this.d = var1.readString(); this.j = (LatLonPoint)var1.readValue(LatLonPoint.class.getClassLoader()); this.k = (LatLonPoint)var1.readValue(LatLonPoint.class.getClassLoader()); this.l = var1.readString(); this.m = var1.readString(); this.n = var1.readString(); boolean[] var2 = new boolean[1]; var1.readBooleanArray(var2); this.s = var2[0]; this.o = var1.readString(); this.p = var1.readString(); this.q = var1.readString(); this.r = var1.readString(); this.u = var1.readString(); this.v = var1.readString(); this.w = var1.readString(); this.x = var1.readArrayList(SubPoiItem.class.getClassLoader()); this.t = (IndoorData)var1.readValue(IndoorData.class.getClassLoader()); this.y = var1.createTypedArrayList(Photo.CREATOR); this.z = (PoiItemExtension)var1.readParcelable(PoiItemExtension.class.getClassLoader()); this.A = var1.readString(); this.B = var1.readString(); } public int describeContents() { return 0; } public void writeToParcel(Parcel var1, int var2) { var1.writeString(this.a); var1.writeString(this.c); var1.writeString(this.b); var1.writeString(this.e); var1.writeInt(this.f); var1.writeValue(this.g); var1.writeString(this.h); var1.writeString(this.i); var1.writeString(this.d); var1.writeValue(this.j); var1.writeValue(this.k); var1.writeString(this.l); var1.writeString(this.m); var1.writeString(this.n); var1.writeBooleanArray(new boolean[]{this.s}); var1.writeString(this.o); var1.writeString(this.p); var1.writeString(this.q); var1.writeString(this.r); var1.writeString(this.u); var1.writeString(this.v); var1.writeString(this.w); var1.writeList(this.x); var1.writeValue(this.t); var1.writeTypedList(this.y); var1.writeParcelable(this.z, var2); var1.writeString(this.A); var1.writeString(this.B); } public boolean equals(Object var1) { if (this == var1) { return true; } else if (var1 == null) { return false; } else if (this.getClass() != var1.getClass()) { return false; } else { var1 = var1; if (this.a == null) { if (var1.a != null) { return false; } } else if (!this.a.equals(var1.a)) { return false; } return true; } } public int hashCode() { return 31 + (this.a == null ? 0 : this.a.hashCode()); } public String toString() { return this.h; } } 你要用的话也就只能用这里面有的。
最新发布
11-28
package com.weishitechsub.kdcxqwb.fragment.Adapter; import android.content.Context; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; import android.util.Log; import android.view.View; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.Toast; import androidx.annotation.NonNull; import com.chad.library.adapter.base.BaseQuickAdapter; import com.chad.library.adapter.base.viewholder.BaseViewHolder; import com.weishitechsub.kdcxqwb.R; import com.weishitechsub.kdcxqwb.bean.ListBean; import com.weishitechsub.kdcxqwb.utils.OnMultiClickListener; import com.weishitechsub.kdcxqwb.utils.PackageNotificationSender; import java.util.List; public class MyCourierAdapter extends BaseQuickAdapter<ListBean, BaseViewHolder> { private static final String TAG = "MyCourierAdapter"; private List<ListBean> mTrackList; private Context context; public MyCourierAdapter(List<ListBean> list, Context context) { super(R.layout.my_courier_adapter,list); this.context = context; } @Override protected void convert(@NonNull BaseViewHolder baseViewHolder, ListBean dataBean) { baseViewHolder.setText(R.id.tv_number,dataBean.getNum()); baseViewHolder.setText(R.id.tv_address,dataBean.getContext()); String state = dataBean.getState(); if (state != null) { if (state.equals("0")) { baseViewHolder.setText(R.id.tv_state,"在途"); }else if ((state.equals("5"))){ baseViewHolder.setText(R.id.tv_state,"派件"); }else if ((state.equals("3"))){ baseViewHolder.setText(R.id.tv_state,"签收"); }else if ((state.equals("6"))){ baseViewHolder.setText(R.id.tv_state,"退回"); }else if ((state.equals("4"))){ baseViewHolder.setText(R.id.tv_state,"退签"); }else { baseViewHolder.setText(R.id.tv_state,"转投"); } } if (dataBean.getType() != null) { String trackName = getTrackName(dataBean.getType()); baseViewHolder.setText(R.id.tv_type,trackName); ImageView logo = baseViewHolder.getView(R.id.iv_logo); LinearLayout line = baseViewHolder.getView(R.id.line); if (trackName.equals("邮政快递包裹")) { baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_yz_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("京东物流")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_jd_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("圆通快递")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_yt_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("中通快递")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_zt_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("顺丰速运")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_sf_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("韵达快递")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_yd_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("申通快递")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_st_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else if (trackName.equals("EMS")){ baseViewHolder.setImageResource(R.id.iv_logo,R.mipmap.img_ems_bg); logo.setVisibility(View.VISIBLE); line.setVisibility(View.GONE); }else { baseViewHolder.setText(R.id.tv_name,trackName); logo.setVisibility(View.GONE); line.setVisibility(View.VISIBLE); } } baseViewHolder.getView(R.id.tv_remind).setOnClickListener(new OnMultiClickListener() { @Override public void onMultiClick(View v) { ListBean item = dataBean; String number = item.getNum(); String type = item.getType(); String companyName = getTrackName(type); // 打印点击事件日志 Log.d(TAG, "用户点击【提醒】按钮: 快递单号=" + number + ", 公司编码=" + type + ", 公司名称=" + companyName); Toast.makeText(v.getContext(), "已设置提醒: " + number, Toast.LENGTH_SHORT).show(); PackageNotificationSender sender = new PackageNotificationSender(context); sender.sendPickupReminder(number, companyName); // 启动轮询查询 startDeliveryPolling(context, number, companyName); } }); } public void setTrackList(List<ListBean> list){ mTrackList = list; } private String getTrackName(String type){ if (mTrackList != null) { for (ListBean item : mTrackList){ if (item.getNum()!= null && type != null ) { if(TextUtils.equals(item.getNum(),type)){ return item.getCom(); } } } } return ""; } private void startDeliveryPolling(Context context, String number, String companyName) { Log.d(TAG, "开始轮询快递状态: 单号=" + number + ", 公司=" + companyName); Handler handler = new Handler(Looper.getMainLooper()); Runnable pollingTask = new Runnable() { int count = 0; boolean arrived = false; @Override public void run() { if (arrived) { Log.d(TAG, "快递已到达,停止轮询: " + number); return; } if (count > 10) { Log.w(TAG, "轮询超过最大次数,停止: " + number); return; } count++; Log.d(TAG, "第 " + count + " 次轮询检查快递状态: " + number); // 模拟第3次轮询时到达 if (count == 3) { arrived = true; Log.i(TAG, "🎉 快递已到达!单号: " + number); PackageNotificationSender sender = new PackageNotificationSender(context); sender.sendPickupReminder(number, companyName); Toast.makeText(context, "快递 " + number + " 已到达!", Toast.LENGTH_LONG).show(); } else { // 继续下一次轮询 Log.d(TAG, "快递未到达," + 10 + "秒后再次检查..."); handler.postDelayed(this, 10000); // 10秒查一次(演示用) } } }; // 首次延迟10秒执行 handler.postDelayed(pollingTask, 10000); } } package com.weishitechsub.kdcxqwb.utils; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import com.weishitechsub.kdcxqwb.R; import com.weishitechsub.kdcxqwb.MainActivity; public class PackageNotificationSender { // 在类顶部定义 TAG,方便过滤日志 private static final String TAG = "MyCourierAdapter"; private static final String CHANNEL_ID = "delivery_reminder_channel"; private static final int NOTIFICATION_ID = 1000; private final Context context; public PackageNotificationSender(Context context) { this.context = context.getApplicationContext(); createNotificationChannel(); } private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { CharSequence name = "快递提醒"; String description = "当快递到达时发出通知"; int importance = NotificationManager.IMPORTANCE_HIGH; NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); channel.setDescription(description); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } public void sendPickupReminder(String number, String company) { Intent intent = new Intent(context, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); String title = "📦 快递待取件"; String content = "您的快递 " + number + "(" + company + ")已到达,请及时取件!"; NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.mipmap.img_icon) // 替换为你自己的图标 .setContentTitle(title) .setContentText(content) .setPriority(NotificationCompat.PRIORITY_HIGH) .setContentIntent(pendingIntent) .setAutoCancel(true) .setDefaults(NotificationCompat.DEFAULT_ALL); NotificationManagerCompat.from(context).notify(NOTIFICATION_ID + number.hashCode(), builder.build()); } } 这个没有在手机系统通知栏里进行通知
11-28
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值